fix(lint): add ruff config, fix all lint errors for GitHub CI
Some checks are pending
CI / Backend (Python) (push) Waiting to run
CI / Frontend (Vue) (push) Waiting to run
Mirror / mirror (push) Waiting to run

- pyproject.toml: add [tool.ruff] config suppressing E402/W293 globally,
  F841/E741/E702 in tests, E741 in scripts
- inventory.py: split semicolon import (E702)
- recipes.py: fix logger -> log (F821 undefined name)
- shopping.py: rename l -> lnk in list comprehension (E741)
- format_conversion.py: noqa F841 on CUDA flag (used as future hook)
- backfill_keywords.py: rename done -> _done (F841)
- ingest_purplecarrot.py: drop == True comparison (E712)
- Auto-fix: I001 import sorting, F401 unused imports across all files
This commit is contained in:
pyr0ball 2026-07-06 02:49:02 -07:00
parent cf807179f5
commit c18bfec8f5
131 changed files with 1082 additions and 458 deletions

View file

@ -88,7 +88,8 @@ CF_APP_NAME=kiwi
# E2E_TEST_USER_ID= # E2E_TEST_USER_ID=
# In-app feedback → Forgejo issue creation # In-app feedback → Forgejo issue creation
# FORGEJO_API_TOKEN= # FORGEJO_API_TOKEN= # dev/admin token
# FORGEJO_BOT_TOKEN= # cf-bugbot token — used for in-app feedback; falls back to FORGEJO_API_TOKEN
# FORGEJO_REPO=Circuit-Forge/kiwi # FORGEJO_REPO=Circuit-Forge/kiwi
# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1 # FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1

View file

@ -22,7 +22,6 @@ import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Request, Response from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse
from app.core.config import settings from app.core.config import settings
from app.services.ap.keys import get_actor from app.services.ap.keys import get_actor
@ -92,7 +91,6 @@ async def _on_follow(activity: dict, headers: dict) -> None:
if not actor_url: if not actor_url:
return return
from app.db.store import Store
from app.core.config import settings as _settings from app.core.config import settings as _settings
db_path = _settings.DB_PATH db_path = _settings.DB_PATH
@ -284,6 +282,7 @@ async def get_following():
def _post_to_ap_note(post, actor, base_url: str) -> dict: def _post_to_ap_note(post, actor, base_url: str) -> dict:
from circuitforge_core.activitypub import make_note from circuitforge_core.activitypub import make_note
from app.services.community.ap_compat import _build_content from app.services.community.ap_compat import _build_content
diet_tags: list[str] = list(getattr(post, "dietary_tags", []) or []) diet_tags: list[str] = list(getattr(post, "dietary_tags", []) or [])

View file

@ -12,7 +12,6 @@ from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
from app.cloud_session import CloudUser, get_session from app.cloud_session import CloudUser, get_session
from app.core.config import settings
from app.db.store import Store from app.db.store import Store
from app.services.community.feed import posts_to_rss from app.services.community.feed import posts_to_rss
@ -36,6 +35,7 @@ def init_community_store(community_db_url: str | None) -> None:
) )
return return
from circuitforge_core.community import CommunityDB from circuitforge_core.community import CommunityDB
from app.services.community.community_store import KiwiCommunityStore from app.services.community.community_store import KiwiCommunityStore
db = CommunityDB(dsn=community_db_url) db = CommunityDB(dsn=community_db_url)
db.run_migrations() db.run_migrations()
@ -307,9 +307,10 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
# AP delivery + Mastodon post (Paid tier, AP_ENABLED, opted-in) # AP delivery + Mastodon post (Paid tier, AP_ENABLED, opted-in)
from app.core.config import settings as _settings from app.core.config import settings as _settings
if _settings.AP_ENABLED and session.tier in ("paid", "premium", "ultra"): if _settings.AP_ENABLED and session.tier in ("paid", "premium", "ultra"):
from circuitforge_core.activitypub import make_create, make_note, PUBLIC from circuitforge_core.activitypub import make_create
from app.services.ap.keys import get_actor
from app.services.ap.delivery import deliver_to_followers from app.services.ap.delivery import deliver_to_followers
from app.services.ap.keys import get_actor
_ap_actor = get_actor() _ap_actor = get_actor()
if _ap_actor is not None: if _ap_actor is not None:
base = f"https://{_settings.AP_HOST}" base = f"https://{_settings.AP_HOST}"

View file

@ -1,5 +1,6 @@
# app/api/endpoints/corrections.py — user corrections to LLM output for SFT training # app/api/endpoints/corrections.py — user corrections to LLM output for SFT training
from circuitforge_core.api import make_corrections_router from circuitforge_core.api import make_corrections_router
from app.db.session import get_db from app.db.session import get_db
router = make_corrections_router(get_db=get_db, product="kiwi") router = make_corrections_router(get_db=get_db, product="kiwi")

View file

@ -1,5 +1,6 @@
"""Feedback router — provided by circuitforge-core.""" """Feedback router — provided by circuitforge-core."""
from circuitforge_core.api import make_feedback_router from circuitforge_core.api import make_feedback_router
from app.core.config import settings from app.core.config import settings
router = make_feedback_router( router = make_feedback_router(

View file

@ -35,9 +35,12 @@ class AttachResponse(BaseModel):
comment_url: str comment_url: str
def _forgejo_token() -> str:
return os.environ.get("FORGEJO_BOT_TOKEN") or os.environ.get("FORGEJO_API_TOKEN", "")
def _forgejo_headers() -> dict[str, str]: def _forgejo_headers() -> dict[str, str]:
token = os.environ.get("FORGEJO_API_TOKEN", "") return {"Authorization": f"token {_forgejo_token()}"}
return {"Authorization": f"token {token}"}
def _decode_image(image_b64: str) -> tuple[bytes, str]: def _decode_image(image_b64: str) -> tuple[bytes, str]:
@ -58,7 +61,7 @@ def attach_screenshot(payload: AttachRequest) -> AttachResponse:
The image is uploaded as an issue asset, then referenced in a comment The image is uploaded as an issue asset, then referenced in a comment
so it is visible inline when the issue is viewed. so it is visible inline when the issue is viewed.
""" """
token = os.environ.get("FORGEJO_API_TOKEN", "") token = _forgejo_token()
if not token: if not token:
raise HTTPException(status_code=503, detail="Feedback not configured.") raise HTTPException(status_code=503, detail="Feedback not configured.")

View file

@ -4,15 +4,13 @@ from __future__ import annotations
import logging import logging
import os import os
import secrets import secrets
from datetime import datetime, timedelta, timezone
import sqlite3 import sqlite3
from datetime import datetime, timedelta, timezone
import requests import requests
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, get_session from app.cloud_session import CLOUD_DATA_ROOT, CloudUser, get_session
from app.services.heimdall_orch import HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN
from app.db.store import Store from app.db.store import Store
from app.models.schemas.household import ( from app.models.schemas.household import (
HouseholdAcceptRequest, HouseholdAcceptRequest,
@ -24,6 +22,7 @@ from app.models.schemas.household import (
HouseholdStatusResponse, HouseholdStatusResponse,
MessageResponse, MessageResponse,
) )
from app.services.heimdall_orch import HEIMDALL_ADMIN_TOKEN, HEIMDALL_URL
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
router = APIRouter() router = APIRouter()

View file

@ -8,7 +8,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app.cloud_session import get_session, CloudUser from app.cloud_session import CloudUser, get_session
from app.db.store import Store from app.db.store import Store
router = APIRouter() router = APIRouter()

View file

@ -6,7 +6,7 @@ import asyncio
import logging import logging
import uuid import uuid
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import List, Optional
import aiofiles import aiofiles
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
@ -114,7 +114,6 @@ async def get_product(product_id: int, store: Store = Depends(get_store)):
@router.get("/products/barcode/{barcode}", response_model=ProductResponse) @router.get("/products/barcode/{barcode}", response_model=ProductResponse)
async def get_product_by_barcode(barcode: str, store: Store = Depends(get_store)): async def get_product_by_barcode(barcode: str, store: Store = Depends(get_store)):
from app.db import store as store_module # avoid circular
product = await asyncio.to_thread( product = await asyncio.to_thread(
store._fetch_one, "SELECT * FROM products WHERE barcode = ?", (barcode,) store._fetch_one, "SELECT * FROM products WHERE barcode = ?", (barcode,)
) )
@ -383,8 +382,8 @@ async def scan_barcode_text(
): ):
"""Scan a barcode from a text string (e.g. from a hardware scanner or manual entry).""" """Scan a barcode from a text string (e.g. from a hardware scanner or manual entry)."""
log.info("scan auth=%s tier=%s barcode=%r", _auth_label(session.user_id), session.tier, body.barcode) log.info("scan auth=%s tier=%s barcode=%r", _auth_label(session.user_id), session.tier, body.barcode)
from app.services.openfoodfacts import OpenFoodFactsService
from app.services.expiration_predictor import ExpirationPredictor from app.services.expiration_predictor import ExpirationPredictor
from app.services.openfoodfacts import OpenFoodFactsService
from app.tiers import can_use from app.tiers import can_use
predictor = ExpirationPredictor() predictor = ExpirationPredictor()
@ -475,8 +474,8 @@ async def scan_barcode_image(
async with aiofiles.open(temp_file, "wb") as f: async with aiofiles.open(temp_file, "wb") as f:
await f.write(await file.read()) await f.write(await file.read())
from app.services.barcode_scanner import BarcodeScanner from app.services.barcode_scanner import BarcodeScanner
from app.services.openfoodfacts import OpenFoodFactsService
from app.services.expiration_predictor import ExpirationPredictor from app.services.expiration_predictor import ExpirationPredictor
from app.services.openfoodfacts import OpenFoodFactsService
image_bytes = temp_file.read_bytes() image_bytes = temp_file.read_bytes()
barcodes = await asyncio.to_thread(BarcodeScanner().scan_from_bytes, image_bytes) barcodes = await asyncio.to_thread(BarcodeScanner().scan_from_bytes, image_bytes)
@ -568,9 +567,10 @@ async def capture_nutrition_label(
for user review. Fields extracted with confidence < 0.7 should be for user review. Fields extracted with confidence < 0.7 should be
highlighted in amber in the UI. highlighted in amber in the UI.
""" """
from app.tiers import can_use
from app.models.schemas.label_capture import LabelCaptureResponse from app.models.schemas.label_capture import LabelCaptureResponse
from app.services.label_capture import extract_label, needs_review as _needs_review from app.services.label_capture import extract_label
from app.services.label_capture import needs_review as _needs_review
from app.tiers import can_use
if not can_use("visual_label_capture", session.tier, session.has_byok): if not can_use("visual_label_capture", session.tier, session.has_byok):
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.") raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
@ -611,9 +611,9 @@ async def confirm_nutrition_label(
resolve instantly without another capture. Optionally adds the item to resolve instantly without another capture. Optionally adds the item to
the user's inventory. the user's inventory.
""" """
from app.tiers import can_use
from app.models.schemas.label_capture import LabelConfirmResponse from app.models.schemas.label_capture import LabelConfirmResponse
from app.services.expiration_predictor import ExpirationPredictor from app.services.expiration_predictor import ExpirationPredictor
from app.tiers import can_use
if not can_use("visual_label_capture", session.tier, session.has_byok): if not can_use("visual_label_capture", session.tier, session.has_byok):
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.") raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
@ -700,7 +700,8 @@ async def create_tag(body: TagCreate, store: Store = Depends(get_store)):
(body.name, body.slug, body.description, body.color, body.category), (body.name, body.slug, body.description, body.color, body.category),
) )
store.conn.commit() store.conn.commit()
import sqlite3; store.conn.row_factory = sqlite3.Row import sqlite3
store.conn.row_factory = sqlite3.Row
return TagResponse.model_validate(store._row_to_dict(cur.fetchone())) return TagResponse.model_validate(store._row_to_dict(cur.fetchone()))

View file

@ -11,7 +11,6 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
@ -42,6 +41,7 @@ async def connect_mastodon(body: dict, session: CloudUser = Depends(get_session)
Returns: {"authorize_url": "..."} Returns: {"authorize_url": "..."}
""" """
import secrets import secrets
from app.services.ap.mastodon import build_authorize_url, register_app from app.services.ap.mastodon import build_authorize_url, register_app
instance_url = (body.get("instance_url") or "").strip().rstrip("/") instance_url = (body.get("instance_url") or "").strip().rstrip("/")

View file

@ -12,6 +12,7 @@ from app.cloud_session import CloudUser, get_session
from app.db.session import get_store from app.db.session import get_store
from app.db.store import Store from app.db.store import Store
from app.models.schemas.meal_plan import ( from app.models.schemas.meal_plan import (
VALID_MEAL_TYPES,
CreatePlanRequest, CreatePlanRequest,
GapItem, GapItem,
PlanSummary, PlanSummary,
@ -22,7 +23,6 @@ from app.models.schemas.meal_plan import (
UpdatePlanRequest, UpdatePlanRequest,
UpdatePrepTaskRequest, UpdatePrepTaskRequest,
UpsertSlotRequest, UpsertSlotRequest,
VALID_MEAL_TYPES,
) )
from app.services.meal_plan.affiliates import get_retailer_links from app.services.meal_plan.affiliates import get_retailer_links
from app.services.meal_plan.prep_scheduler import build_prep_tasks from app.services.meal_plan.prep_scheduler import build_prep_tasks

View file

@ -15,9 +15,9 @@ from app.core.config import settings
from app.db.session import get_store from app.db.session import get_store
from app.db.store import Store from app.db.store import Store
from app.models.schemas.receipt import ( from app.models.schemas.receipt import (
ApprovedInventoryItem,
ApproveOCRRequest, ApproveOCRRequest,
ApproveOCRResponse, ApproveOCRResponse,
ApprovedInventoryItem,
) )
from app.services.expiration_predictor import ExpirationPredictor from app.services.expiration_predictor import ExpirationPredictor
from app.tiers import can_use from app.tiers import can_use

View file

@ -14,8 +14,8 @@ from app.cloud_session import CloudUser, get_session
from app.core.config import settings from app.core.config import settings
from app.db.session import get_store from app.db.session import get_store
from app.db.store import Store from app.db.store import Store
from app.models.schemas.receipt import ReceiptResponse
from app.models.schemas.quality import QualityAssessment from app.models.schemas.quality import QualityAssessment
from app.models.schemas.receipt import ReceiptResponse
from app.tiers import can_use from app.tiers import can_use
router = APIRouter() router = APIRouter()

View file

@ -222,7 +222,7 @@ async def _scan_recipe_sse(saved_paths: list[Path], pantry_names: list[str]):
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)}) loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
except RuntimeError as exc: except RuntimeError as exc:
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)}) loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
except Exception as exc: except Exception:
logger.exception("Unexpected error in recipe scan thread") logger.exception("Unexpected error in recipe scan thread")
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": "Scan failed unexpectedly."}) loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": "Scan failed unexpectedly."})

View file

@ -114,7 +114,6 @@ async def submit_recipe_tag(
_validate_location(body.domain, body.category, body.subcategory) _validate_location(body.domain, body.category, body.subcategory)
try: try:
import psycopg2.errors # type: ignore[import]
row = store.submit_recipe_tag( row = store.submit_recipe_tag(
recipe_id=body.recipe_id, recipe_id=body.recipe_id,
domain=body.domain, domain=body.domain,

View file

@ -2,23 +2,24 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json as _json_mod
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Annotated from typing import Annotated
import json as _json_mod
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from app.cloud_session import CloudUser, _auth_label, get_session from app.cloud_session import CloudUser, _auth_label, get_session
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
from app.api.endpoints.imitate import _build_recipe_prompt
from app.db.session import get_store from app.db.session import get_store
from app.db.store import Store from app.db.store import Store
from app.models.schemas.recipe import ( from app.models.schemas.recipe import (
AskRecipeHit,
AskRequest, AskRequest,
AskResponse, AskResponse,
AskRecipeHit,
AssemblyTemplateOut, AssemblyTemplateOut,
BuildRequest, BuildRequest,
LeftoversResponse, LeftoversResponse,
@ -31,7 +32,7 @@ from app.models.schemas.recipe import (
StreamTokenResponse, StreamTokenResponse,
) )
from app.services.coordinator_proxy import CoordinatorError, coordinator_authorize from app.services.coordinator_proxy import CoordinatorError, coordinator_authorize
from app.api.endpoints.imitate import _build_recipe_prompt from app.services.heimdall_orch import check_orch_budget
from app.services.recipe.assembly_recipes import ( from app.services.recipe.assembly_recipes import (
build_from_selection, build_from_selection,
get_role_candidates, get_role_candidates,
@ -47,9 +48,8 @@ from app.services.recipe.browser_domains import (
get_subcategory_names, get_subcategory_names,
) )
from app.services.recipe.recipe_engine import RecipeEngine from app.services.recipe.recipe_engine import RecipeEngine
from app.services.recipe.time_effort import parse_time_effort
from app.services.recipe.sensory import build_sensory_exclude from app.services.recipe.sensory import build_sensory_exclude
from app.services.heimdall_orch import check_orch_budget from app.services.recipe.time_effort import parse_time_effort
from app.tiers import can_use from app.tiers import can_use
router = APIRouter() router = APIRouter()
@ -149,7 +149,9 @@ async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
""" """
import json import json
import uuid import uuid
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from app.cloud_session import CLOUD_MODE from app.cloud_session import CLOUD_MODE
from app.tasks.runner import insert_task from app.tasks.runner import insert_task
@ -495,7 +497,7 @@ async def browse_recipes(
"community_tagged": True, "community_tagged": True,
} }
except Exception as exc: except Exception as exc:
logger.warning("community tag fallback failed: %s", exc) log.warning("community tag fallback failed: %s", exc)
store.log_browser_telemetry( store.log_browser_telemetry(
domain=domain, domain=domain,
@ -576,7 +578,8 @@ async def build_recipe(
return None return None
# Persist to recipes table so the result can be saved/bookmarked. # Persist to recipes table so the result can be saved/bookmarked.
# external_id encodes template + selections for stable dedup. # external_id encodes template + selections for stable dedup.
import hashlib as _hl, json as _js import hashlib as _hl
import json as _js
sel_hash = _hl.md5( sel_hash = _hl.md5(
_js.dumps(req.role_overrides, sort_keys=True).encode() _js.dumps(req.role_overrides, sort_keys=True).encode()
).hexdigest()[:8] ).hexdigest()[:8]

View file

@ -43,7 +43,7 @@ def _enrich(item: dict, builder: GroceryLinkBuilder) -> ShoppingItemResponse:
links = builder.build_links(item["name"]) links = builder.build_links(item["name"])
return ShoppingItemResponse( return ShoppingItemResponse(
**{**item, "checked": bool(item.get("checked", 0))}, **{**item, "checked": bool(item.get("checked", 0))},
grocery_links=[{"ingredient": l.ingredient, "retailer": l.retailer, "url": l.url} for l in links], grocery_links=[{"ingredient": lnk.ingredient, "retailer": lnk.retailer, "url": lnk.url} for lnk in links],
) )

View file

@ -1,5 +1,24 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
from app.api.endpoints import (
export,
feedback,
feedback_attach,
health,
household,
imitate,
inventory,
meal_plans,
ocr,
orch_usage,
receipts,
recipes,
saved_recipes,
session,
settings,
shopping,
staples,
)
from app.api.endpoints.community import router as community_router from app.api.endpoints.community import router as community_router
from app.api.endpoints.corrections import router as corrections_router from app.api.endpoints.corrections import router as corrections_router
from app.api.endpoints.mastodon_oauth import router as mastodon_router from app.api.endpoints.mastodon_oauth import router as mastodon_router

View file

@ -18,7 +18,8 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from circuitforge_core.cloud_session import CloudSessionFactory as _CoreFactory, detect_byok from circuitforge_core.cloud_session import CloudSessionFactory as _CoreFactory
from circuitforge_core.cloud_session import detect_byok
from fastapi import Depends, HTTPException, Request, Response from fastapi import Depends, HTTPException, Request, Response
log = logging.getLogger(__name__) log = logging.getLogger(__name__)

View file

@ -4,28 +4,28 @@ This file is kept for historical reference only. Nothing imports it.
""" """
# fmt: off # noqa — dead file, not linted # fmt: off # noqa — dead file, not linted
import uuid
from datetime import datetime
from sqlalchemy import ( from sqlalchemy import (
Column,
String,
Text,
Boolean, Boolean,
Numeric,
DateTime,
Date,
ForeignKey,
CheckConstraint, CheckConstraint,
Column,
Date,
DateTime,
ForeignKey,
Index, Index,
Numeric,
String,
Table, Table,
Text,
) )
from sqlalchemy.dialects.postgresql import UUID, JSONB from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func
from datetime import datetime
import uuid
from app.db.base import Base from app.db.base import Base
# Association table for many-to-many relationship between products and tags # Association table for many-to-many relationship between products and tags
product_tags = Table( product_tags = Table(
"product_tags", "product_tags",

View file

@ -11,6 +11,7 @@ from typing import Any
from circuitforge_core.db.base import get_connection from circuitforge_core.db.base import get_connection
from circuitforge_core.db.migrations import run_migrations from circuitforge_core.db.migrations import run_migrations
from app.services.recipe.sensory import SensoryExclude, passes_sensory_filter from app.services.recipe.sensory import SensoryExclude, passes_sensory_filter
MIGRATIONS_DIR = Path(__file__).parent / "migrations" MIGRATIONS_DIR = Path(__file__).parent / "migrations"
@ -304,6 +305,7 @@ class Store:
Returns (updated_count, skipped_count). Returns (updated_count, skipped_count).
""" """
from datetime import date from datetime import date
from app.services.expiration_predictor import ExpirationPredictor from app.services.expiration_predictor import ExpirationPredictor
predictor = ExpirationPredictor() predictor = ExpirationPredictor()
@ -573,13 +575,26 @@ class Store:
if pattern in lower: if pattern in lower:
terms.append(canonical) terms.append(canonical)
# For multi-word product names, also add individual significant tokens # For multi-word product names, also add individual significant tokens.
# Threshold is 2 (skip only 1-char tokens) so short-but-meaningful
# ingredients like "egg", "oil", "soy" are included in FTS queries.
# Also add singular/plural variants for each token so "Cage Free Eggs"
# generates "egg" as a search term alongside "eggs".
if " " in lower: if " " in lower:
for token in lower.split(): for token in lower.split():
if len(token) <= 3 or token in Store._FTS_TOKEN_STOPWORDS: if len(token) <= 2 or token in Store._FTS_TOKEN_STOPWORDS:
continue continue
if token not in terms: if token not in terms:
terms.append(token) terms.append(token)
# Add singular/plural variant so "eggs" also searches "egg" etc.
if token.endswith("es") and len(token) > 4:
variant = token[:-2] # "tomatoes" → "tomato"
elif token.endswith("s") and len(token) > 3:
variant = token[:-1] # "eggs" → "egg", "florets" → "floret"
else:
variant = token + "s" # "egg" → "eggs"
if variant not in terms:
terms.append(variant)
# Synonym-expand individual tokens too # Synonym-expand individual tokens too
if token in Store._FTS_SYNONYMS: if token in Store._FTS_SYNONYMS:
canonical = Store._FTS_SYNONYMS[token] canonical = Store._FTS_SYNONYMS[token]

View file

@ -73,7 +73,9 @@ async def lifespan(app: FastAPI):
try: try:
from app.db.store import _COUNT_CACHE from app.db.store import _COUNT_CACHE
from app.services.recipe.browse_counts_cache import ( from app.services.recipe.browse_counts_cache import (
is_stale, load_into_memory, refresh, is_stale,
load_into_memory,
refresh,
) )
if is_stale(settings.BROWSE_COUNTS_PATH): if is_stale(settings.BROWSE_COUNTS_PATH):
logger.info("browse_counts: cache stale — refreshing in background...") logger.info("browse_counts: cache stale — refreshing in background...")
@ -116,6 +118,7 @@ app.include_router(api_router, prefix=settings.API_PREFIX)
# AP endpoints: WebFinger at root (not under /api/v1), AP objects under /ap # AP endpoints: WebFinger at root (not under /api/v1), AP objects under /ap
from app.api.endpoints.activitypub import ap_router, webfinger_router from app.api.endpoints.activitypub import ap_router, webfinger_router
app.include_router(webfinger_router) app.include_router(webfinger_router)
app.include_router(ap_router) app.include_router(ap_router)

View file

@ -1,4 +1,4 @@
from app.models.schemas.receipt import ReceiptResponse
from app.models.schemas.quality import QualityAssessment from app.models.schemas.quality import QualityAssessment
from app.models.schemas.receipt import ReceiptResponse
__all__ = ["ReceiptResponse", "QualityAssessment"] __all__ = ["ReceiptResponse", "QualityAssessment"]

View file

@ -2,12 +2,11 @@
from __future__ import annotations from __future__ import annotations
from datetime import date, datetime from datetime import date
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# ── Tags ────────────────────────────────────────────────────────────────────── # ── Tags ──────────────────────────────────────────────────────────────────────
class TagCreate(BaseModel): class TagCreate(BaseModel):

View file

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional from typing import List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field

View file

@ -6,7 +6,6 @@ from datetime import date as _date
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"} VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"}

View file

@ -3,10 +3,11 @@
Pydantic schemas for OCR data models. Pydantic schemas for OCR data models.
""" """
from datetime import datetime, date, time from datetime import date, datetime, time
from typing import Optional, List, Dict, Any from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field, validator
from pydantic import BaseModel, Field
class MerchantInfo(BaseModel): class MerchantInfo(BaseModel):

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List from typing import Any, Dict, List
from pydantic import BaseModel from pydantic import BaseModel

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field

View file

@ -9,7 +9,6 @@ from __future__ import annotations
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# ── Ingredient in a scanned recipe ──────────────────────────────────────────── # ── Ingredient in a scanned recipe ────────────────────────────────────────────
class ScannedIngredientSchema(BaseModel): class ScannedIngredientSchema(BaseModel):

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field

View file

@ -6,13 +6,13 @@ from images (UPC, EAN, QR codes, etc.).
""" """
import io import io
import logging
from pathlib import Path
from typing import Any, Dict, List
import cv2 import cv2
import numpy as np import numpy as np
from pyzbar import pyzbar from pyzbar import pyzbar
from pathlib import Path
from typing import List, Dict, Any, Optional
import logging
try: try:
from PIL import Image as _PILImage from PIL import Image as _PILImage

View file

@ -10,9 +10,10 @@ Fallback path: LLMRouter — only fires for unknown products when tier allows it
import logging import logging
import re import re
from datetime import date, timedelta from datetime import date, timedelta
from typing import Optional, List from typing import List, Optional
from circuitforge_core.llm.router import LLMRouter from circuitforge_core.llm.router import LLMRouter
from app.tiers import can_use from app.tiers import can_use
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -6,13 +6,12 @@ This module provides functionality to convert receipt and quality assessment
data into spreadsheet formats for easy viewing and analysis. data into spreadsheet formats for easy viewing and analysis.
""" """
import pandas as pd from typing import Dict, List, Optional
from datetime import datetime
from typing import List, Dict, Optional import pandas as pd
from pathlib import Path
from app.models.schemas.receipt import ReceiptResponse
from app.models.schemas.quality import QualityAssessment from app.models.schemas.quality import QualityAssessment
from app.models.schemas.receipt import ReceiptResponse
class SpreadsheetExporter: class SpreadsheetExporter:

View file

@ -4,7 +4,10 @@ Image preprocessing services for Kiwi.
Contains functions for image enhancement, format conversion, and perspective correction. Contains functions for image enhancement, format conversion, and perspective correction.
""" """
from app.services.image_preprocessing.format_conversion import convert_to_standard_format, extract_metadata from app.services.image_preprocessing.enhancement import correct_perspective, enhance_image
from app.services.image_preprocessing.enhancement import enhance_image, correct_perspective from app.services.image_preprocessing.format_conversion import (
convert_to_standard_format,
extract_metadata,
)
__all__ = ["convert_to_standard_format", "extract_metadata", "enhance_image", "correct_perspective"] __all__ = ["convert_to_standard_format", "extract_metadata", "enhance_image", "correct_perspective"]

View file

@ -1,10 +1,11 @@
#!/usr/bin/env python #!/usr/bin/env python
# app/services/image_preprocessing/ # app/services/image_preprocessing/
import cv2
import numpy as np
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Tuple, Optional from typing import Optional, Tuple
import cv2
import numpy as np
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -1,10 +1,10 @@
#!/usr/bin/env python #!/usr/bin/env python
# app/services/image_preprocessing/format_conversion.py # app/services/image_preprocessing/format_conversion.py
import cv2
import numpy as np
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Tuple, Optional from typing import Optional, Tuple
import cv2
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -28,10 +28,10 @@ def convert_to_standard_format(
# Check if CUDA is available and set up GPU processing # Check if CUDA is available and set up GPU processing
if cv2.cuda.getCudaEnabledDeviceCount() > 0: if cv2.cuda.getCudaEnabledDeviceCount() > 0:
logger.info("CUDA available, using GPU acceleration") logger.info("CUDA available, using GPU acceleration")
use_cuda = True use_cuda = True # noqa: F841
else: else:
logger.info("CUDA not available, using CPU processing") logger.info("CUDA not available, using CPU processing")
use_cuda = False use_cuda = False # noqa: F841
# Read image # Read image
img = cv2.imread(str(image_path)) img = cv2.imread(str(image_path))

View file

@ -8,31 +8,31 @@ This service orchestrates:
- Tag management - Tag management
""" """
from sqlalchemy.ext.asyncio import AsyncSession import logging
from sqlalchemy import select, func, and_, or_ import uuid
from sqlalchemy.orm import selectinload
from typing import List, Optional, Dict, Any
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional
from uuid import UUID from uuid import UUID
import uuid
import logging
from app.db.models import Product, InventoryItem, Tag, product_tags from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.db.models import InventoryItem, Product, Tag
from app.models.schemas.inventory import ( from app.models.schemas.inventory import (
ProductCreate,
ProductUpdate,
ProductResponse,
InventoryItemCreate, InventoryItemCreate,
InventoryItemUpdate,
InventoryItemResponse, InventoryItemResponse,
TagCreate, InventoryItemUpdate,
TagResponse,
InventoryStats, InventoryStats,
ProductCreate,
ProductResponse,
ProductUpdate,
TagCreate,
) )
from app.services.barcode_scanner import BarcodeScanner from app.services.barcode_scanner import BarcodeScanner
from app.services.openfoodfacts import OpenFoodFactsService
from app.services.expiration_predictor import ExpirationPredictor from app.services.expiration_predictor import ExpirationPredictor
from app.services.openfoodfacts import OpenFoodFactsService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -15,7 +15,7 @@ from __future__ import annotations
import logging import logging
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass
from typing import Any from typing import Any
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -12,7 +12,6 @@ from urllib.parse import quote_plus
from circuitforge_core.affiliates import AffiliateProgram, register_program, wrap_url from circuitforge_core.affiliates import AffiliateProgram, register_program, wrap_url
# ── URL builders ────────────────────────────────────────────────────────────── # ── URL builders ──────────────────────────────────────────────────────────────
def _walmart_search(url: str, affiliate_id: str) -> str: def _walmart_search(url: str, affiliate_id: str) -> str:

View file

@ -10,17 +10,13 @@ import json
import logging import logging
import os import os
import re import re
from pathlib import Path
from typing import Dict, Any, Optional, List
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import Any, Dict
from PIL import Image
import torch import torch
from transformers import ( from PIL import Image
Qwen2VLForConditionalGeneration, from transformers import AutoProcessor, BitsAndBytesConfig, Qwen2VLForConditionalGeneration
AutoProcessor,
BitsAndBytesConfig
)
from app.core.config import settings from app.core.config import settings
@ -35,8 +31,8 @@ def _try_docuvision(image_path: str | Path) -> str | None:
# Tier 1: task-based routing — coordinator owns model selection. # Tier 1: task-based routing — coordinator owns model selection.
try: try:
from app.services.task_inference import task_allocate, TaskNotRegistered
from app.services.ocr.docuvision_client import DocuvisionClient from app.services.ocr.docuvision_client import DocuvisionClient
from app.services.task_inference import TaskNotRegistered, task_allocate
try: try:
with task_allocate( with task_allocate(
"kiwi", "ocr", "kiwi", "ocr",
@ -57,6 +53,7 @@ def _try_docuvision(image_path: str | Path) -> str | None:
# Tier 2: direct allocation — hardcoded service type. # Tier 2: direct allocation — hardcoded service type.
try: try:
from circuitforge_orch.client import CFOrchClient from circuitforge_orch.client import CFOrchClient
from app.services.ocr.docuvision_client import DocuvisionClient from app.services.ocr.docuvision_client import DocuvisionClient
client = CFOrchClient(cf_orch_url) client = CFOrchClient(cf_orch_url)

View file

@ -5,10 +5,10 @@ This module provides functionality to look up product information
from the OpenFoodFacts database using barcodes (UPC/EAN). from the OpenFoodFacts database using barcodes (UPC/EAN).
""" """
import httpx
from typing import Optional, Dict, Any
from app.core.config import settings
import logging import logging
from typing import Any, Dict, Optional
import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -1,10 +1,11 @@
#!/usr/bin/env python #!/usr/bin/env python
# app/services/quality/assessment.py # app/services/quality/assessment.py
import cv2
import numpy as np
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Dict, Any, Optional, Tuple from typing import Any, Dict, Tuple
import cv2
import numpy as np
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -1,23 +1,22 @@
#!/usr/bin/env python #!/usr/bin/env python
# app/services/receipt_service.py # app/services/receipt_service.py
import os
import uuid
import shutil
import aiofiles
from pathlib import Path
from typing import Optional, List, Dict, Any
from fastapi import UploadFile, BackgroundTasks, HTTPException
import asyncio
import logging import logging
import sys import uuid
from app.utils.progress import ProgressIndicator from pathlib import Path
from typing import Dict, List, Optional
import aiofiles
from fastapi import BackgroundTasks, HTTPException, UploadFile
from app.services.image_preprocessing.format_conversion import convert_to_standard_format, extract_metadata
from app.services.image_preprocessing.enhancement import enhance_image, correct_perspective
from app.services.quality.assessment import QualityAssessor
from app.models.schemas.receipt import ReceiptCreate, ReceiptResponse
from app.models.schemas.quality import QualityAssessment
from app.core.config import settings from app.core.config import settings
from app.models.schemas.quality import QualityAssessment
from app.models.schemas.receipt import ReceiptResponse
from app.services.image_preprocessing.enhancement import correct_perspective, enhance_image
from app.services.image_preprocessing.format_conversion import (
convert_to_standard_format,
extract_metadata,
)
from app.services.quality.assessment import QualityAssessor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -22,7 +22,6 @@ from dataclasses import dataclass
from app.models.schemas.recipe import RecipeSuggestion from app.models.schemas.recipe import RecipeSuggestion
# IDs in range -100..-1 are reserved for assembly-generated suggestions # IDs in range -100..-1 are reserved for assembly-generated suggestions
_ASSEMBLY_ID_START = -1 _ASSEMBLY_ID_START = -1

View file

@ -20,13 +20,21 @@ from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from app.db.store import Store from app.db.store import Store
from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest, RecipeResult, RecipeSuggestion, StepAnalysis, TimeEffortProfile, SwapCandidate from app.models.schemas.recipe import (
NutritionPanel,
RecipeRequest,
RecipeResult,
RecipeSuggestion,
StepAnalysis,
SwapCandidate,
TimeEffortProfile,
)
from app.services.recipe.element_classifier import ElementClassifier from app.services.recipe.element_classifier import ElementClassifier
from app.services.recipe.grocery_links import GroceryLinkBuilder from app.services.recipe.grocery_links import GroceryLinkBuilder
from app.services.recipe.substitution_engine import SubstitutionEngine
from app.services.recipe.sensory import SensoryExclude, build_sensory_exclude, passes_sensory_filter
from app.services.recipe.time_effort import parse_time_effort
from app.services.recipe.reranker import rerank_suggestions from app.services.recipe.reranker import rerank_suggestions
from app.services.recipe.sensory import build_sensory_exclude, passes_sensory_filter
from app.services.recipe.substitution_engine import SubstitutionEngine
from app.services.recipe.time_effort import parse_time_effort
_LEFTOVER_DAILY_MAX_FREE = 5 _LEFTOVER_DAILY_MAX_FREE = 5
@ -398,9 +406,19 @@ def _expand_pantry_set(
# Extract individual ingredient tokens from multi-word product names. # Extract individual ingredient tokens from multi-word product names.
# "Organic Extra Firm Tofu" → adds "tofu"; "Brown Basmati Rice" → adds "rice". # "Organic Extra Firm Tofu" → adds "tofu"; "Brown Basmati Rice" → adds "rice".
# This catches plain ingredients that _PANTRY_LABEL_SYNONYMS doesn't translate. # This catches plain ingredients that _PANTRY_LABEL_SYNONYMS doesn't translate.
# Threshold is 3 chars (not 4) so short-but-meaningful ingredients like "egg",
# "oil", and "soy" are captured from packaged product names.
for token in lower.split(): for token in lower.split():
if len(token) >= 4 and token not in _PRODUCT_TOKEN_STOPWORDS: if len(token) >= 3 and token not in _PRODUCT_TOKEN_STOPWORDS:
expanded.add(token) expanded.add(token)
# Also add singular/plural variant so corpus "egg" matches pantry "eggs"
# and vice versa. Simple heuristic: strip/add trailing 's'.
if token.endswith("es") and len(token) > 4:
expanded.add(token[:-2]) # "tomatoes" → "tomato"
elif token.endswith("s") and len(token) > 3:
expanded.add(token[:-1]) # "eggs" → "egg", "florets" → "floret"
else:
expanded.add(token + "s") # "egg" → "eggs", "floret" → "florets"
# Secondary state expansion — adds terms like "stale bread", "day-old rice" # Secondary state expansion — adds terms like "stale bread", "day-old rice"
if secondary_pantry_items and item in secondary_pantry_items: if secondary_pantry_items and item in secondary_pantry_items:
@ -432,14 +450,33 @@ def _ingredient_in_pantry(ingredient: str, pantry_set: set[str]) -> bool:
if pattern in clean and canonical in pantry_set: if pattern in clean and canonical in pantry_set:
return True return True
# Single-token ingredient whose token appears in pantry (e.g. "ketchup" in "c. ketchup") # Single-token or multi-token ingredient whose tokens appear in pantry.
# (e.g. "ketchup" in "c. ketchup", "diced onions" when "onion" is in pantry)
# Also accepts singular/plural variants so "egg" matches "eggs" and vice versa.
tokens = [t for t in clean.split() if t not in _SWAP_STOPWORDS and len(t) > 2] tokens = [t for t in clean.split() if t not in _SWAP_STOPWORDS and len(t) > 2]
if tokens and all(t in pantry_set for t in tokens): if tokens and all(_token_in_pantry(t, pantry_set) for t in tokens):
return True return True
return False return False
def _token_in_pantry(token: str, pantry_set: set[str]) -> bool:
"""Return True if `token` or a simple plural/singular variant is in pantry_set."""
if token in pantry_set:
return True
# Try adding/removing trailing 's' / 'es' for plural normalization
if token.endswith("es") and len(token) > 4:
if token[:-2] in pantry_set:
return True
elif token.endswith("s") and len(token) > 3:
if token[:-1] in pantry_set:
return True
else:
if (token + "s") in pantry_set:
return True
return False
def _content_tokens(text: str) -> frozenset[str]: def _content_tokens(text: str) -> frozenset[str]:
return frozenset( return frozenset(
w for w in text.lower().split() w for w in text.lower().split()

View file

@ -152,8 +152,8 @@ def _call_via_local_vlm(image_paths: list[Path], prompt: str) -> str:
raise RuntimeError("No CUDA device -- local VLM unavailable") raise RuntimeError("No CUDA device -- local VLM unavailable")
# Lazy import so the module loads fast when GPU is absent # Lazy import so the module loads fast when GPU is absent
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
from PIL import Image, ImageOps from PIL import Image, ImageOps
from transformers import AutoProcessor, Qwen2VLForConditionalGeneration
model_name = "Qwen/Qwen2.5-VL-7B-Instruct" model_name = "Qwen/Qwen2.5-VL-7B-Instruct"
logger.info("Loading local VLM for recipe scan: %s", model_name) logger.info("Loading local VLM for recipe scan: %s", model_name)
@ -271,10 +271,11 @@ def _call_vision_backend(
cf_orch_url = os.environ.get("CF_ORCH_URL") cf_orch_url = os.environ.get("CF_ORCH_URL")
if cf_orch_url: if cf_orch_url:
try: try:
from app.services.task_inference import TaskNotRegistered, task_allocate
from app.services.ocr.docuvision_client import DocuvisionClient
from circuitforge_core.llm.router import LLMRouter from circuitforge_core.llm.router import LLMRouter
from app.services.ocr.docuvision_client import DocuvisionClient
from app.services.task_inference import TaskNotRegistered, task_allocate
try: try:
_progress("allocating", "Starting vision service...") _progress("allocating", "Starting vision service...")
with task_allocate("kiwi", "recipe_scan", service_hint="cf-docuvision", ttl_s=120.0) as alloc: with task_allocate("kiwi", "recipe_scan", service_hint="cf-docuvision", ttl_s=120.0) as alloc:

View file

@ -13,7 +13,7 @@ Environment:
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass, field from dataclasses import dataclass
from app.models.schemas.recipe import RecipeRequest, RecipeSuggestion from app.models.schemas.recipe import RecipeRequest, RecipeSuggestion

View file

@ -51,8 +51,9 @@ def _build_router():
cf_orch_url = os.environ.get("CF_ORCH_URL") cf_orch_url = os.environ.get("CF_ORCH_URL")
if cf_orch_url: if cf_orch_url:
try: try:
from app.services.meal_plan.llm_router import _OrchTextRouter # reuse adapter
from circuitforge_orch.client import CFOrchClient from circuitforge_orch.client import CFOrchClient
from app.services.meal_plan.llm_router import _OrchTextRouter # reuse adapter
client = CFOrchClient(cf_orch_url) client = CFOrchClient(cf_orch_url)
ctx = client.allocate(service=_SERVICE_TYPE, ttl_s=_TTL_S, caller=_CALLER) ctx = client.allocate(service=_SERVICE_TYPE, ttl_s=_TTL_S, caller=_CALLER)
alloc = ctx.__enter__() alloc = ctx.__enter__()

View file

@ -7,7 +7,6 @@ Powered by:
""" """
from __future__ import annotations from __future__ import annotations
import json
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING from typing import TYPE_CHECKING

View file

@ -24,7 +24,6 @@ from __future__ import annotations
import re import re
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Text-signal tables # Text-signal tables
# (tag, [case-insensitive substrings to search in combined title+ingredient text]) # (tag, [case-insensitive substrings to search in combined title+ingredient text])

View file

@ -11,7 +11,11 @@ from pathlib import Path
from circuitforge_core.tasks.scheduler import ( from circuitforge_core.tasks.scheduler import (
TaskScheduler, TaskScheduler,
)
from circuitforge_core.tasks.scheduler import (
get_scheduler as _base_get_scheduler, get_scheduler as _base_get_scheduler,
)
from circuitforge_core.tasks.scheduler import (
reset_scheduler as _reset_local, # re-export for tests reset_scheduler as _reset_local, # re-export for tests
) )

View file

@ -8,7 +8,7 @@ Uses circuitforge-core can_use() with Kiwi's feature map.
""" """
from __future__ import annotations from __future__ import annotations
from circuitforge_core.tiers.tiers import can_use as _can_use, BYOK_UNLOCKABLE from circuitforge_core.tiers.tiers import can_use as _can_use
# Features that unlock when the user supplies their own LLM backend. # Features that unlock when the user supplies their own LLM backend.
KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({ KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
@ -49,20 +49,18 @@ KIWI_FEATURES: dict[str, str] = {
"recipe_suggestions": "paid", # BYOK-unlockable "recipe_suggestions": "paid", # BYOK-unlockable
"expiry_llm_matching": "paid", # BYOK-unlockable "expiry_llm_matching": "paid", # BYOK-unlockable
"meal_planning": "free", "meal_planning": "free",
"meal_plan_config": "paid", # configurable meal types (breakfast/lunch/snack) "meal_plan_config": "paid", # configurable meal types (breakfast/lunch/snack)
"meal_plan_llm": "paid", # LLM-assisted full-week plan generation; BYOK-unlockable
"meal_plan_llm_timing": "paid", # LLM time fill-in for recipes missing corpus times; BYOK-unlockable
"dietary_profiles": "paid", "dietary_profiles": "paid",
"style_picker": "paid", "style_picker": "paid",
"recipe_collections": "paid", "recipe_collections": "paid",
"style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable "community_publish": "paid", # Publish plans/outcomes to community feed
"community_publish": "paid", # Publish plans/outcomes to community feed
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
# Paid tier (continued) # Premium tier — heavy LLM features (model call per request)
"recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe "meal_plan_llm": "premium", # LLM-assisted full-week plan generation; BYOK-unlockable
"meal_plan_llm_timing": "premium", # LLM time fill-in for recipes missing corpus times; BYOK-unlockable
# Premium tier "style_classifier": "premium", # LLM auto-tag for saved recipe style tags; BYOK-unlockable
"community_fork_adapt": "premium", # Fork with LLM pantry adaptation; BYOK-unlockable
"recipe_scan": "premium", # BYOK-unlockable: photo -> structured recipe
"multi_household": "premium", "multi_household": "premium",
"background_monitoring": "premium", "background_monitoring": "premium",
} }

View file

@ -1,12 +1,13 @@
# app/utils/progress.py # app/utils/progress.py
import sys
import time
import asyncio import asyncio
from typing import Optional, Callable, Any import sys
import threading import threading
import time
from typing import Any, Callable, Optional
class ProgressIndicator: class ProgressIndicator:
""" r"""
A simple progress indicator for long-running operations. A simple progress indicator for long-running operations.
This class provides different styles of progress indicators: This class provides different styles of progress indicators:

View file

@ -0,0 +1,10 @@
/* Dark mode image treatment — app screenshots are light-mode only, soften them on slate */
[data-md-color-scheme="slate"] img:not([src*=".svg"]) {
filter: brightness(0.82) contrast(1.05);
border-radius: 4px;
}
/* SVGs (icons, diagrams) inherit theme color naturally — no filter needed */
[data-md-color-scheme="slate"] img[src*=".svg"] {
filter: none;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View file

@ -48,21 +48,25 @@
<label class="form-label">Title <span class="form-required">*</span></label> <label class="form-label">Title <span class="form-required">*</span></label>
<input <input
v-model="form.title" v-model="form.title"
class="form-input" :class="['form-input', { 'field-invalid': touched.title && !form.title.trim() }]"
type="text" type="text"
placeholder="Short summary of the issue or idea" placeholder="Short summary of the issue or idea"
maxlength="120" maxlength="120"
@blur="touched.title = true"
/> />
<p v-if="touched.title && !form.title.trim()" class="field-error">Required</p>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Description <span class="form-required">*</span></label> <label class="form-label">Description <span class="form-required">*</span></label>
<textarea <textarea
v-model="form.description" v-model="form.description"
class="form-input feedback-textarea" :class="['form-input', 'feedback-textarea', { 'field-invalid': touched.description && !form.description.trim() }]"
placeholder="Describe what happened or what you'd like to see…" placeholder="Describe what happened or what you'd like to see…"
rows="4" rows="4"
@blur="touched.description = true"
/> />
<p v-if="touched.description && !form.description.trim()" class="field-error">Required</p>
</div> </div>
<div v-if="form.type === 'bug'" class="form-group"> <div v-if="form.type === 'bug'" class="form-group">
@ -135,6 +139,8 @@
v-if="step === 1" v-if="step === 1"
class="btn btn-primary" class="btn btn-primary"
@click="nextStep" @click="nextStep"
:disabled="!canProceed"
:title="!canProceed ? 'Fill in Title and Description to continue' : undefined"
>Next </button> >Next </button>
<button <button
v-if="step === 2 && !submitted" v-if="step === 2 && !submitted"
@ -151,7 +157,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, reactive } from 'vue'
const props = defineProps<{ currentTab?: string }>() const props = defineProps<{ currentTab?: string }>()
@ -215,6 +221,9 @@ const form = ref({
submitter: '', submitter: '',
}) })
const touched = reactive({ title: false, description: false })
const canProceed = computed(() => !!form.value.title.trim() && !!form.value.description.trim())
const typeLabel = computed(() => types.find(t => t.value === form.value.type)?.label ?? '') const typeLabel = computed(() => types.find(t => t.value === form.value.type)?.label ?? '')
function close() { function close() {
@ -231,13 +240,16 @@ function reset() {
submitted.value = false submitted.value = false
issueUrl.value = '' issueUrl.value = ''
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' } form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
touched.title = false
touched.description = false
clearScreenshot() clearScreenshot()
} }
function nextStep() { function nextStep() {
touched.title = true
touched.description = true
stepError.value = '' stepError.value = ''
if (!form.value.title.trim() || !form.value.description.trim()) { if (!canProceed.value) {
stepError.value = 'Please fill in both Title and Description.'
return return
} }
step.value = 2 step.value = 2
@ -297,7 +309,7 @@ async function submit() {
.feedback-fab { .feedback-fab {
position: fixed; position: fixed;
right: var(--spacing-md); right: var(--spacing-md);
bottom: calc(68px + var(--spacing-md)); /* above mobile bottom nav */ bottom: calc(68px + env(safe-area-inset-bottom, 0px) + var(--spacing-md)); /* above mobile bottom nav + system gesture nav */
z-index: 190; z-index: 190;
display: flex; display: flex;
align-items: center; align-items: center;
@ -326,7 +338,7 @@ async function submit() {
/* On desktop, bottom nav is gone — drop to standard corner */ /* On desktop, bottom nav is gone — drop to standard corner */
@media (min-width: 769px) { @media (min-width: 769px) {
.feedback-fab { .feedback-fab {
bottom: var(--spacing-lg); bottom: calc(var(--spacing-lg) + env(safe-area-inset-bottom, 0px));
} }
} }
@ -413,10 +425,18 @@ async function submit() {
justify-content: flex-end; justify-content: flex-end;
gap: var(--spacing-sm); gap: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-md);
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px)); /* clear home indicator on mobile bottom sheet */
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
flex-shrink: 0; flex-shrink: 0;
} }
/* When modal is centered (not bottom sheet), safe-area padding isn't needed */
@media (min-width: 500px) {
.feedback-footer {
padding-bottom: var(--spacing-sm);
}
}
.feedback-textarea { .feedback-textarea {
resize: vertical; resize: vertical;
min-height: 80px; min-height: 80px;
@ -426,6 +446,21 @@ async function submit() {
.form-required { color: var(--color-error); margin-left: 2px; } .form-required { color: var(--color-error); margin-left: 2px; }
.field-invalid {
border-color: var(--color-error) !important;
background: color-mix(in srgb, var(--color-error) 5%, var(--color-bg-secondary));
}
.field-invalid:focus {
border-color: var(--color-error) !important;
outline-color: var(--color-error);
}
.field-error {
color: var(--color-error);
font-size: 0.75rem;
margin: 2px 0 0;
}
.feedback-error { .feedback-error {
color: var(--color-error); color: var(--color-error);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);

View file

@ -382,6 +382,7 @@ export const inventoryAPI = {
headers: { headers: {
'Content-Type': 'multipart/form-data', 'Content-Type': 'multipart/form-data',
}, },
timeout: 90000, // OFF lookup + LLM expiry prediction can take 30-60s on cold start
}) })
return response.data return response.data
}, },

View file

@ -9,14 +9,14 @@ theme:
name: material name: material
palette: palette:
- scheme: default - scheme: default
primary: light green primary: green
accent: green accent: teal
toggle: toggle:
icon: material/brightness-7 icon: material/brightness-7
name: Switch to dark mode name: Switch to dark mode
- scheme: slate - scheme: slate
primary: light green primary: green
accent: green accent: lime
toggle: toggle:
icon: material/brightness-4 icon: material/brightness-4
name: Switch to light mode name: Switch to light mode
@ -64,5 +64,8 @@ nav:
- Tier System: reference/tier-system.md - Tier System: reference/tier-system.md
- Architecture: reference/architecture.md - Architecture: reference/architecture.md
extra_css:
- stylesheets/theme.css
extra_javascript: extra_javascript:
- plausible.js - plausible.js

View file

@ -35,3 +35,22 @@ include = ["app*"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
asyncio_mode = "auto" asyncio_mode = "auto"
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "W", "I"]
ignore = [
"E501", # line length — handled by formatter
"E402", # module-import-not-at-top — intentional for conditional/lazy imports in endpoints
"W293", # whitespace before newline on blank lines — cosmetic, fixed by formatter
]
[tool.ruff.lint.per-file-ignores]
# tests/: F841 unused variables are the standard mock-patch capture pattern.
# E741 ambiguous names and E402 conditional imports are common in test fixtures.
"tests/**" = ["F841", "E741", "E702"]
# scripts/: E741 ambiguous loop vars are common in data pipeline scripts.
"scripts/**" = ["E741"]

View file

@ -92,7 +92,7 @@ def backfill(db_path: Path, batch_size: int = 5000) -> None:
fixed += len(updates) fixed += len(updates)
offset += batch_size offset += batch_size
done = offset + len(rows) - (batch_size - len(rows)) _done = offset + len(rows) - (batch_size - len(rows)) # noqa: F841
pct = min(100, int((offset / total) * 100)) pct = min(100, int((offset / total) * 100))
print(f" {pct:>3}% processed {offset:,} fixed {fixed:,} skipped {skipped:,}", end="\r") print(f" {pct:>3}% processed {offset:,} fixed {fixed:,} skipped {skipped:,}", end="\r")

129
scripts/logo/composite.py Normal file
View file

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""
Kiwi logo compositor.
Takes:
- A kiwifruit disc SVG (generated by generate_disc.py)
- A kiwi bird PNG (AI-generated via ComfyUI img2img then PIL-corrected)
Composites bird over disc, removes white background, outputs icon sizes.
Usage:
conda run -n cf python3 scripts/logo/composite.py \
--disc scripts/logo/kiwi_disc.svg \
--bird scripts/logo/kiwi_bird_final.png \
--out frontend/public/icons/
Requirements:
cairosvg, Pillow, numpy
"""
import argparse
import io
from pathlib import Path
import cairosvg
import numpy as np
from PIL import Image, ImageDraw
SIZES = [512, 256, 192, 64]
def remove_background(bird_img: Image.Image) -> Image.Image:
"""Remove white/gray background from AI-generated bird PNG."""
arr = np.array(bird_img, dtype=np.float32)
r, g, b = arr[:, :, 0], arr[:, :, 1], arr[:, :, 2]
brightness = (r + g + b) / 3.0
saturation = np.max([r, g, b], axis=0) - np.min([r, g, b], axis=0)
near_white = (brightness > 228) & (saturation < 28)
# Remove gray ground shadow in bottom 30% of image only
h = arr.shape[0]
gray_shadow = np.zeros(arr.shape[:2], dtype=bool)
gray_shadow[int(h * 0.70):, :] = (
(brightness[int(h * 0.70):, :] > 135) &
(saturation[int(h * 0.70):, :] < 65)
)
gray_shadow2 = np.zeros(arr.shape[:2], dtype=bool)
gray_shadow2[int(h * 0.78):, :] = (
(brightness[int(h * 0.78):, :] > 110) &
(saturation[int(h * 0.78):, :] < 75)
)
transparent = near_white | gray_shadow | gray_shadow2
# Soft edge
soft = (brightness > 205) & (saturation < 55) & ~transparent
soft_alpha = np.where(soft, ((255.0 - brightness) / 50.0) * 255.0, 0.0)
new_alpha = np.where(transparent, 0.0, np.where(soft, soft_alpha, 255.0))
arr[:, :, 3] = np.clip(new_alpha, 0, 255)
return Image.fromarray(arr.astype(np.uint8))
def composite(disc_svg: Path, bird_png: Path, out_dir: Path) -> None:
out_dir.mkdir(parents=True, exist_ok=True)
# Render disc at max size
max_size = max(SIZES)
disc_bytes = cairosvg.svg2png(url=str(disc_svg), output_width=max_size, output_height=max_size)
disc = Image.open(io.BytesIO(disc_bytes)).convert("RGBA")
# Load and de-background bird
bird_raw = Image.open(bird_png).convert("RGBA")
bird_nobg = remove_background(bird_raw)
bbox = bird_nobg.getbbox()
bird_crop = bird_nobg.crop(bbox)
# Scale bird to 80% of disc diameter
target_w = int(max_size * 0.80)
target_h = int(bird_crop.height * target_w / bird_crop.width)
bird_scaled = bird_crop.resize((target_w, target_h), Image.LANCZOS)
# Composite centered, slightly raised and shifted right (beak faces left)
cx, cy = max_size // 2, max_size // 2
x_off = cx - bird_scaled.width // 2 + int(max_size * 0.06)
y_off = cy - bird_scaled.height // 2 - int(max_size * 0.04)
result = disc.copy()
result.paste(bird_scaled, (x_off, y_off), bird_scaled)
# Draw eye post-composite (eye was at 305,335 in 768px bird space)
# Eye was at pixel (305, 335) in original 768px bird image
# Adjust for crop bbox offset, then scale to composite space
sx = target_w / bird_crop.width
sy = target_h / bird_crop.height
# Known eye coords from PIL correction pass (EX=305, EY=335 in 768px space)
eye_x = int(x_off + (305 - bbox[0]) * sx)
eye_y = int(y_off + (335 - bbox[1]) * sy)
draw = ImageDraw.Draw(result)
draw.ellipse([eye_x - 7, eye_y - 7, eye_x + 7, eye_y + 7], fill=(240, 235, 220, 255))
draw.ellipse([eye_x - 3, eye_y - 3, eye_x + 3, eye_y + 3], fill=(12, 5, 2, 255))
# Export all sizes
for size in SIZES:
out_path = out_dir / f"icon-{size}.png"
resized = result.resize((size, size), Image.LANCZOS)
resized.save(out_path)
print(f" Saved {out_path} ({size}px)")
# Maskable copies (same image — kiwi disc has safe-zone padding built in)
for size in [192, 512]:
src = out_dir / f"icon-{size}.png"
dst = out_dir / f"maskable-{size}.png"
dst.write_bytes(src.read_bytes())
print(f" Copied {dst}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Kiwi logo compositor")
parser.add_argument("--disc", type=Path, default=Path("scripts/logo/kiwi_disc.svg"))
parser.add_argument("--bird", type=Path, default=Path("scripts/logo/kiwi_bird_final.png"))
parser.add_argument("--out", type=Path, default=Path("frontend/public/icons"))
args = parser.parse_args()
print(f"Compositing {args.bird.name} onto {args.disc.name}{args.out}/")
composite(args.disc, args.bird, args.out)
print("Done.")

View file

@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
Draw a kiwi bird (Apteryx) silhouette as SVG for use as ControlNet guidance.
Key anatomy:
- Very round oval body (nearly spherical)
- Small oval head, short neck
- EXTREMELY long thin downward-curved beak ( body height)
- No visible wings (vestigial, hidden in feathers)
- Short stocky legs with 3 forward toes + 1 rear toe
Rendered as colored (rufous brown) on white, 768×768 gives img2img a palette to work with.
"""
import math
W, H = 768, 768
# Kiwi bird colour palette
COL_BODY = "#7A3B20" # dark rufous brown
COL_HEAD = "#8C4828" # slightly lighter brown for head
COL_NECK = "#7A3B20" # same as body
COL_BEAK = "#C8A96E" # horn/tan beak
COL_LEG = "#4A2410" # very dark brown legs/feet
COL_EYE_RG = "white" # eye ring
COL_PUPIL = "#1A0A00" # near-black pupil
def pt(x, y): return f"{x:.1f},{y:.1f}"
def bezier_arc(cx, cy, rx, ry, start_deg, end_deg, steps=32):
"""Approximate an ellipse arc as a polyline."""
points = []
for i in range(steps + 1):
t = math.radians(start_deg + (end_deg - start_deg) * i / steps)
x = cx + rx * math.cos(t)
y = cy + ry * math.sin(t)
points.append((x, y))
return points
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {W} {H}" width="{W}" height="{H}">',
' <rect width="100%" height="100%" fill="white"/>',
]
# ── Body ────────────────────────────────────────────────────────────────────
BCX, BCY = 430, 440
BRX, BRY = 155, 175
lines.append(f' <ellipse cx="{BCX}" cy="{BCY}" rx="{BRX}" ry="{BRY}" fill="{COL_BODY}"/>')
# ── Head ────────────────────────────────────────────────────────────────────
HCX, HCY = 315, 295
HRX, HRY = 60, 52
lines.append(f' <ellipse cx="{HCX}" cy="{HCY}" rx="{HRX}" ry="{HRY}" fill="{COL_HEAD}"/>')
# ── Neck ────────────────────────────────────────────────────────────────────
neck_path = (
f'M {pt(HCX - 25, HCY + 42)} '
f'Q {pt(BCX - 140, BCY - 120)} {pt(BCX - 125, BCY - 148)} '
f'L {pt(BCX - 60, BCY - 165)} '
f'Q {pt(HCX + 30, HCY - 30)} {pt(HCX + 30, HCY + 38)} '
f'Z'
)
lines.append(f' <path d="{neck_path}" fill="{COL_NECK}"/>')
# ── Beak ────────────────────────────────────────────────────────────────────
# Starts at left side of head, curves to a point
# Kiwi beak: starts narrow at head, stays thin all the way, slight downward arc
beak_base_x = HCX - HRX + 5 # where beak attaches to head (left side)
beak_base_y = HCY + 5
beak_tip_x = 118 # very long — extends well past the head
beak_tip_y = HCY + 28 # droops slightly at the tip
# Upper beak edge: from base, curving gently down to tip
# Lower beak edge: parallel, slightly below
beak_w_base = 14 # width at head
beak_w_mid = 8 # width at midpoint
mid_x = (beak_base_x + beak_tip_x) / 2
mid_y_upper = (beak_base_y - beak_w_base/2 + beak_tip_y - 2) / 2 - 2
mid_y_lower = (beak_base_y + beak_w_base/2 + beak_tip_y + 2) / 2 + 2
beak_path = (
f'M {pt(beak_base_x, beak_base_y - beak_w_base/2)} '
f'Q {pt(mid_x, mid_y_upper)} {pt(beak_tip_x, beak_tip_y)} '
f'Q {pt(mid_x, mid_y_lower)} {pt(beak_base_x, beak_base_y + beak_w_base/2)} '
f'Z'
)
lines.append(f' <path d="{beak_path}" fill="{COL_BEAK}"/>')
# ── Legs ────────────────────────────────────────────────────────────────────
def draw_leg(lines, hip_x, foot_x, foot_y, toe_spread=28):
"""Draw a leg with a 3-toed foot."""
hip_y = BCY + BRY - 20
ankle_y = foot_y - 22
leg_w = 20
leg_path = (
f'M {pt(hip_x - leg_w/2, hip_y)} '
f'L {pt(foot_x - leg_w/2, ankle_y)} '
f'L {pt(foot_x + leg_w/2, ankle_y)} '
f'L {pt(hip_x + leg_w/2, hip_y)} Z'
)
lines.append(f' <path d="{leg_path}" fill="{COL_LEG}"/>')
# Three forward toes + one small rear toe
toe_len = 38
toe_w = 7
angles = [-25, 0, 25] # degrees from horizontal
for a in angles:
rad = math.radians(a)
tx = foot_x + toe_len * math.cos(rad)
ty = ankle_y + toe_len * math.sin(rad)
# Toe as thin tapered path
perp = math.radians(a + 90)
toe_path = (
f'M {pt(foot_x + toe_w * math.cos(perp), ankle_y + toe_w * math.sin(perp))} '
f'L {pt(tx + 2, ty)} '
f'L {pt(foot_x - toe_w * math.cos(perp), ankle_y - toe_w * math.sin(perp))} Z'
)
lines.append(f' <path d="{toe_path}" fill="{COL_LEG}"/>')
# Rear hallux
rear_x = foot_x - 20
rear_y = ankle_y + 8
toe_path = (
f'M {pt(foot_x - 4, ankle_y)} '
f'L {pt(rear_x, rear_y)} '
f'L {pt(foot_x + 4, ankle_y)} Z'
)
lines.append(f' <path d="{toe_path}" fill="{COL_LEG}"/>')
# Left leg (slightly forward)
draw_leg(lines, hip_x=BCX - 38, foot_x=BCX - 45, foot_y=BCY + BRY + 52)
# Right leg (slightly behind)
draw_leg(lines, hip_x=BCX + 28, foot_x=BCX + 38, foot_y=BCY + BRY + 45)
# ── Eye ─────────────────────────────────────────────────────────────────────
# Small white circle (eye highlight) on the head
# Kiwi eye is tiny, close to the beak base
eye_x = HCX - 18
eye_y = HCY - 4
lines.append(f' <circle cx="{eye_x}" cy="{eye_y}" r="10" fill="{COL_EYE_RG}"/>')
lines.append(f' <circle cx="{eye_x}" cy="{eye_y}" r="5" fill="{COL_PUPIL}"/>')
lines.append('</svg>')
svg = '\n'.join(lines)
out = '/tmp/kiwi_silhouette.svg'
with open(out, 'w') as f:
f.write(svg)
print(f"Written: {out}")

View file

@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Generate a kiwifruit cross-section disc as SVG."""
import math
cx, cy, r = 200, 200, 190 # center and total radius
# Color palette — kiwifruit cross section
SKIN_OUTER = "#5C3D1E" # dark brown outer skin
SKIN_INNER = "#7A5230" # lighter brown inner skin edge
FLESH = "#7DC242" # bright kiwi green flesh
FLESH_DARK = "#5A9A2A" # deeper green for radial segment lines
PITH = "#F2EDD7" # cream/white center pith
PITH_SHADOW = "#D9D0B0" # slight shadow on pith edge
SEED_COLOR = "#2C1A0E" # very dark brown seeds
SEED_HILITE = "#5C3820" # seed highlight
def polar(cx, cy, radius, angle_deg):
a = math.radians(angle_deg)
return cx + radius * math.cos(a), cy + radius * math.sin(a)
lines = ['<?xml version="1.0" encoding="UTF-8"?>']
lines.append('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="512" height="512">')
lines.append(' <defs>')
# Radial gradient for flesh (slightly darker at edges)
lines.append(' <radialGradient id="fleshGrad" cx="50%" cy="50%" r="50%">')
lines.append(f' <stop offset="0%" stop-color="{FLESH}" stop-opacity="1"/>')
lines.append(f' <stop offset="75%" stop-color="{FLESH}" stop-opacity="1"/>')
lines.append(f' <stop offset="100%" stop-color="{FLESH_DARK}" stop-opacity="1"/>')
lines.append(' </radialGradient>')
# Radial gradient for pith
lines.append(' <radialGradient id="pithGrad" cx="50%" cy="50%" r="50%">')
lines.append(' <stop offset="0%" stop-color="#FFFFFF" stop-opacity="1"/>')
lines.append(f' <stop offset="70%" stop-color="{PITH}" stop-opacity="1"/>')
lines.append(f' <stop offset="100%" stop-color="{PITH_SHADOW}" stop-opacity="1"/>')
lines.append(' </radialGradient>')
lines.append(' </defs>')
# 1. Outer skin (full circle, darkest brown)
lines.append(f' <circle cx="{cx}" cy="{cy}" r="{r}" fill="{SKIN_OUTER}"/>')
# 2. Inner skin ring (slightly lighter, slightly smaller)
lines.append(f' <circle cx="{cx}" cy="{cy}" r="{r - 12}" fill="{SKIN_INNER}"/>')
# 3. Green flesh
flesh_r = r - 22
lines.append(f' <circle cx="{cx}" cy="{cy}" r="{flesh_r}" fill="url(#fleshGrad)"/>')
# 4. Radial segment lines (subtle darker green spokes)
num_segments = 24
pith_r = 42
seed_ring_r = flesh_r * 0.52 # seeds sit about halfway between pith and skin
for i in range(num_segments):
angle = (360 / num_segments) * i
x1, y1 = polar(cx, cy, pith_r + 8, angle)
x2, y2 = polar(cx, cy, flesh_r - 4, angle)
lines.append(f' <line x1="{x1:.1f}" y1="{y1:.1f}" x2="{x2:.1f}" y2="{y2:.1f}" '
f'stroke="{FLESH_DARK}" stroke-width="1.2" stroke-opacity="0.5"/>')
# 5. Seeds — teardrop shapes arranged in a ring
num_seeds = 22
seed_major = 9 # half-length along radial axis
seed_minor = 5.5 # half-width
for i in range(num_seeds):
angle_deg = (360 / num_seeds) * i - 90 # start from top
angle_rad = math.radians(angle_deg)
# Seed center position
sx = cx + seed_ring_r * math.cos(angle_rad)
sy = cy + seed_ring_r * math.sin(angle_rad)
# Seed is an ellipse rotated to point toward center
rotate_angle = angle_deg + 90 # perpendicular to radial = tangent orientation
# Actually seeds point radially (like a pie slice tip toward center)
rotate_angle = angle_deg # point toward center
lines.append(f' <ellipse cx="{sx:.1f}" cy="{sy:.1f}" '
f'rx="{seed_minor}" ry="{seed_major}" '
f'fill="{SEED_COLOR}" '
f'transform="rotate({rotate_angle:.1f},{sx:.1f},{sy:.1f})"/>')
# Seed highlight (small inner ellipse, slightly offset)
hx = sx - seed_minor * 0.25 * math.cos(angle_rad + math.pi/2)
hy = sy - seed_minor * 0.25 * math.sin(angle_rad + math.pi/2)
lines.append(f' <ellipse cx="{hx:.1f}" cy="{hy:.1f}" '
f'rx="{seed_minor * 0.35:.1f}" ry="{seed_major * 0.35:.1f}" '
f'fill="{SEED_HILITE}" opacity="0.6" '
f'transform="rotate({rotate_angle:.1f},{hx:.1f},{hy:.1f})"/>')
# 6. Pith center (cream/white, slightly star-shaped via polygon)
# Use a slightly irregular star for the pith boundary — more organic
num_pith_points = 12
pith_points = []
for i in range(num_pith_points):
angle = (360 / num_pith_points) * i - 90
# Alternate between outer and inner radius for star effect
pr = pith_r if i % 2 == 0 else pith_r * 0.80
px, py = polar(cx, cy, pr, angle)
pith_points.append(f"{px:.1f},{py:.1f}")
lines.append(f' <polygon points="{" ".join(pith_points)}" fill="url(#pithGrad)"/>')
# 7. Tiny dot at absolute center
lines.append(f' <circle cx="{cx}" cy="{cy}" r="5" fill="{PITH_SHADOW}"/>')
lines.append('</svg>')
svg_content = '\n'.join(lines)
output_path = '/tmp/kiwi_disc.svg'
with open(output_path, 'w') as f:
f.write(svg_content)
print(f"Written to {output_path}")
print(f"Lines: {len(lines)}, Size: {len(svg_content)} bytes")

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View file

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="512" height="512">
<defs>
<radialGradient id="fleshGrad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#7DC242" stop-opacity="1"/>
<stop offset="75%" stop-color="#7DC242" stop-opacity="1"/>
<stop offset="100%" stop-color="#5A9A2A" stop-opacity="1"/>
</radialGradient>
<radialGradient id="pithGrad" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#FFFFFF" stop-opacity="1"/>
<stop offset="70%" stop-color="#F2EDD7" stop-opacity="1"/>
<stop offset="100%" stop-color="#D9D0B0" stop-opacity="1"/>
</radialGradient>
</defs>
<circle cx="200" cy="200" r="190" fill="#5C3D1E"/>
<circle cx="200" cy="200" r="178" fill="#7A5230"/>
<circle cx="200" cy="200" r="168" fill="url(#fleshGrad)"/>
<line x1="250.0" y1="200.0" x2="364.0" y2="200.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="248.3" y1="212.9" x2="358.4" y2="242.4" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="243.3" y1="225.0" x2="342.0" y2="282.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="235.4" y1="235.4" x2="316.0" y2="316.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="225.0" y1="243.3" x2="282.0" y2="342.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="212.9" y1="248.3" x2="242.4" y2="358.4" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="200.0" y1="250.0" x2="200.0" y2="364.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="187.1" y1="248.3" x2="157.6" y2="358.4" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="175.0" y1="243.3" x2="118.0" y2="342.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="164.6" y1="235.4" x2="84.0" y2="316.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="156.7" y1="225.0" x2="58.0" y2="282.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="151.7" y1="212.9" x2="41.6" y2="242.4" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="150.0" y1="200.0" x2="36.0" y2="200.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="151.7" y1="187.1" x2="41.6" y2="157.6" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="156.7" y1="175.0" x2="58.0" y2="118.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="164.6" y1="164.6" x2="84.0" y2="84.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="175.0" y1="156.7" x2="118.0" y2="58.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="187.1" y1="151.7" x2="157.6" y2="41.6" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="200.0" y1="150.0" x2="200.0" y2="36.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="212.9" y1="151.7" x2="242.4" y2="41.6" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="225.0" y1="156.7" x2="282.0" y2="58.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="235.4" y1="164.6" x2="316.0" y2="84.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="243.3" y1="175.0" x2="342.0" y2="118.0" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<line x1="248.3" y1="187.1" x2="358.4" y2="157.6" stroke="#5A9A2A" stroke-width="1.2" stroke-opacity="0.5"/>
<ellipse cx="200.0" cy="112.6" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(-90.0,200.0,112.6)"/>
<ellipse cx="198.6" cy="112.6" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(-90.0,198.6,112.6)"/>
<ellipse cx="224.6" cy="116.2" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(-73.6,224.6,116.2)"/>
<ellipse cx="223.3" cy="115.8" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(-73.6,223.3,115.8)"/>
<ellipse cx="247.2" cy="126.5" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(-57.3,247.2,126.5)"/>
<ellipse cx="246.1" cy="125.8" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(-57.3,246.1,125.8)"/>
<ellipse cx="266.0" cy="142.8" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(-40.9,266.0,142.8)"/>
<ellipse cx="265.1" cy="141.8" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(-40.9,265.1,141.8)"/>
<ellipse cx="279.5" cy="163.7" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(-24.5,279.5,163.7)"/>
<ellipse cx="278.9" cy="162.5" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(-24.5,278.9,162.5)"/>
<ellipse cx="286.5" cy="187.6" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(-8.2,286.5,187.6)"/>
<ellipse cx="286.3" cy="186.2" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(-8.2,286.3,186.2)"/>
<ellipse cx="286.5" cy="212.4" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(8.2,286.5,212.4)"/>
<ellipse cx="286.7" cy="211.1" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(8.2,286.7,211.1)"/>
<ellipse cx="279.5" cy="236.3" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(24.5,279.5,236.3)"/>
<ellipse cx="280.0" cy="235.0" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(24.5,280.0,235.0)"/>
<ellipse cx="266.0" cy="257.2" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(40.9,266.0,257.2)"/>
<ellipse cx="266.9" cy="256.2" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(40.9,266.9,256.2)"/>
<ellipse cx="247.2" cy="273.5" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(57.3,247.2,273.5)"/>
<ellipse cx="248.4" cy="272.7" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(57.3,248.4,272.7)"/>
<ellipse cx="224.6" cy="283.8" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(73.6,224.6,283.8)"/>
<ellipse cx="225.9" cy="283.4" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(73.6,225.9,283.4)"/>
<ellipse cx="200.0" cy="287.4" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(90.0,200.0,287.4)"/>
<ellipse cx="201.4" cy="287.4" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(90.0,201.4,287.4)"/>
<ellipse cx="175.4" cy="283.8" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(106.4,175.4,283.8)"/>
<ellipse cx="176.7" cy="284.2" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(106.4,176.7,284.2)"/>
<ellipse cx="152.8" cy="273.5" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(122.7,152.8,273.5)"/>
<ellipse cx="153.9" cy="274.2" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(122.7,153.9,274.2)"/>
<ellipse cx="134.0" cy="257.2" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(139.1,134.0,257.2)"/>
<ellipse cx="134.9" cy="258.2" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(139.1,134.9,258.2)"/>
<ellipse cx="120.5" cy="236.3" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(155.5,120.5,236.3)"/>
<ellipse cx="121.1" cy="237.5" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(155.5,121.1,237.5)"/>
<ellipse cx="113.5" cy="212.4" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(171.8,113.5,212.4)"/>
<ellipse cx="113.7" cy="213.8" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(171.8,113.7,213.8)"/>
<ellipse cx="113.5" cy="187.6" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(188.2,113.5,187.6)"/>
<ellipse cx="113.3" cy="188.9" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(188.2,113.3,188.9)"/>
<ellipse cx="120.5" cy="163.7" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(204.5,120.5,163.7)"/>
<ellipse cx="120.0" cy="165.0" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(204.5,120.0,165.0)"/>
<ellipse cx="134.0" cy="142.8" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(220.9,134.0,142.8)"/>
<ellipse cx="133.1" cy="143.8" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(220.9,133.1,143.8)"/>
<ellipse cx="152.8" cy="126.5" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(237.3,152.8,126.5)"/>
<ellipse cx="151.6" cy="127.3" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(237.3,151.6,127.3)"/>
<ellipse cx="175.4" cy="116.2" rx="5.5" ry="9" fill="#2C1A0E" transform="rotate(253.6,175.4,116.2)"/>
<ellipse cx="174.1" cy="116.6" rx="1.9" ry="3.1" fill="#5C3820" opacity="0.6" transform="rotate(253.6,174.1,116.6)"/>
<polygon points="200.0,158.0 216.8,170.9 236.4,179.0 233.6,200.0 236.4,221.0 216.8,229.1 200.0,242.0 183.2,229.1 163.6,221.0 166.4,200.0 163.6,179.0 183.2,170.9" fill="url(#pithGrad)"/>
<circle cx="200" cy="200" r="5" fill="#D9D0B0"/>
</svg>

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -10,6 +10,7 @@ Usage:
--flavorgraph-dir /tmp/flavorgraph/input --flavorgraph-dir /tmp/flavorgraph/input
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import sqlite3 import sqlite3

View file

@ -8,6 +8,7 @@ Usage:
--usda-branded data/usda_branded.parquet --usda-branded data/usda_branded.parquet
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import re import re
@ -16,7 +17,6 @@ from pathlib import Path
import pandas as pd import pandas as pd
# ── Element derivation rules (threshold-based) ──────────────────────────── # ── Element derivation rules (threshold-based) ────────────────────────────
_ELEMENT_RULES: list[tuple[str, callable]] = [ _ELEMENT_RULES: list[tuple[str, callable]] = [

View file

@ -8,6 +8,7 @@ Usage:
--batch-size 10000 --batch-size 10000
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import re import re

View file

@ -9,6 +9,7 @@ Usage:
--recipepairs-recipes data/pipeline/recipepairs_recipes.parquet --recipepairs-recipes data/pipeline/recipepairs_recipes.parquet
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import re import re

View file

@ -11,6 +11,7 @@ Downloads:
- lishuyang/recipepairs (GPL-3.0 ) data/pipeline/recipepairs.parquet [derive only, don't ship] - lishuyang/recipepairs (GPL-3.0 ) data/pipeline/recipepairs.parquet [derive only, don't ship]
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import os import os
import shutil import shutil
@ -19,7 +20,6 @@ from pathlib import Path
from datasets import load_dataset from datasets import load_dataset
from huggingface_hub import hf_hub_download from huggingface_hub import hf_hub_download
# Standard HuggingFace datasets: (hf_path, split, output_filename) # Standard HuggingFace datasets: (hf_path, split, output_filename)
HF_DATASETS = [ HF_DATASETS = [
("corbt/all-recipes", "train", "recipes_allrecipes.parquet"), ("corbt/all-recipes", "train", "recipes_allrecipes.parquet"),

View file

@ -13,6 +13,7 @@ Usage:
--db /path/to/kiwi.db --db /path/to/kiwi.db
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json import json
import sqlite3 import sqlite3

View file

@ -28,7 +28,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from app.services.recipe.tag_inferrer import infer_tags from app.services.recipe.tag_inferrer import infer_tags
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Substitution constraint label mapping # Substitution constraint label mapping
# Keys are what we store in substitution_pairs.constraint_label. # Keys are what we store in substitution_pairs.constraint_label.

View file

@ -15,11 +15,10 @@ from __future__ import annotations
import argparse import argparse
import json import json
import sqlite3
from pathlib import Path
import math import math
import re import re
import sqlite3
from pathlib import Path
import pandas as pd import pandas as pd
@ -98,7 +97,7 @@ def ingest(db_path: Path, parquet_path: Path) -> None:
# Filter to rows with full recipe data # Filter to rows with full recipe data
if "HasFullRecipe" in df.columns: if "HasFullRecipe" in df.columns:
df = df[df["HasFullRecipe"] == True].copy() df = df[df["HasFullRecipe"]].copy()
if df.empty: if df.empty:
print("No full recipes found in parquet — nothing to ingest.") print("No full recipes found in parquet — nothing to ingest.")

View file

@ -109,11 +109,11 @@ def main() -> None:
for s in slugs: for s in slugs:
print(f" {s}") print(f" {s}")
print(f"\nSaved {len(df_new)} total slugs (accumulated) to {args.out}") print(f"\nSaved {len(df_new)} total slugs (accumulated) to {args.out}")
print(f"\nTo scrape full recipes:") print("\nTo scrape full recipes:")
print(f" conda run -n cf python3 scripts/pipeline/purple_carrot/scrape_live.py \\") print(" conda run -n cf python3 scripts/pipeline/purple_carrot/scrape_live.py \\")
print(f" --slugs-from {args.out} \\") print(f" --slugs-from {args.out} \\")
print(f" --out /Library/Assets/kiwi/pipeline/recipes_purplecarrot_live.parquet \\") print(" --out /Library/Assets/kiwi/pipeline/recipes_purplecarrot_live.parquet \\")
print(f" --resume") print(" --resume")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -25,7 +25,6 @@ import logging
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import urlencode
import requests import requests

View file

@ -13,14 +13,14 @@ Usage:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import json
import re import re
import time import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import pandas as pd import pandas as pd
from playwright.sync_api import sync_playwright, Page, TimeoutError as PWTimeout from playwright.sync_api import Page, sync_playwright
from playwright.sync_api import TimeoutError as PWTimeout
# ── Config ───────────────────────────────────────────────────────────────────── # ── Config ─────────────────────────────────────────────────────────────────────

View file

@ -1,6 +1,4 @@
"""Tests for active_min/passive_min fields on browse endpoint responses.""" """Tests for active_min/passive_min fields on browse endpoint responses."""
import pytest
from unittest.mock import MagicMock, patch
from app.services.recipe.time_effort import parse_time_effort from app.services.recipe.time_effort import parse_time_effort
@ -92,7 +90,6 @@ class TestDetailTimeEffortField:
def test_time_effort_field_structure(self): def test_time_effort_field_structure(self):
"""Detail endpoint must return the full TimeEffortProfile shape.""" """Detail endpoint must return the full TimeEffortProfile shape."""
import json
from app.services.recipe.time_effort import parse_time_effort from app.services.recipe.time_effort import parse_time_effort
directions = [ directions = [

View file

@ -1,7 +1,8 @@
# tests/api/test_community_endpoints.py # tests/api/test_community_endpoints.py
import pytest from unittest.mock import MagicMock, patch
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app from app.main import app
client = TestClient(app) client = TestClient(app)

View file

@ -4,12 +4,10 @@ from __future__ import annotations
from collections.abc import Callable from collections.abc import Callable
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from circuitforge_core.api.feedback import make_feedback_router
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from circuitforge_core.api.feedback import make_feedback_router
# ── Test app factory ────────────────────────────────────────────────────────── # ── Test app factory ──────────────────────────────────────────────────────────
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient: def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:

View file

@ -5,15 +5,14 @@ Tests for the visual label capture API endpoints (kiwi#79):
GET /api/v1/inventory/scan/text cache hit + needs_visual_capture flag GET /api/v1/inventory/scan/text cache hit + needs_visual_capture flag
""" """
import io import io
import os
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from app.main import app import pytest
from fastapi.testclient import TestClient
from app.cloud_session import get_session from app.cloud_session import get_session
from app.db.session import get_store from app.db.session import get_store
from app.main import app
client = TestClient(app) client = TestClient(app)
@ -213,7 +212,7 @@ class TestScanTextWithCaptureGating:
def _off_patch(self, result): def _off_patch(self, result):
"""Patch OpenFoodFactsService.lookup_product at the class level.""" """Patch OpenFoodFactsService.lookup_product at the class level."""
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock
return patch( return patch(
"app.services.openfoodfacts.OpenFoodFactsService.lookup_product", "app.services.openfoodfacts.OpenFoodFactsService.lookup_product",
new=AsyncMock(return_value=result), new=AsyncMock(return_value=result),

View file

@ -2,7 +2,6 @@
"""Integration tests for /api/v1/meal-plans/ endpoints.""" """Integration tests for /api/v1/meal-plans/ endpoints."""
from __future__ import annotations from __future__ import annotations
import json
from unittest.mock import MagicMock from unittest.mock import MagicMock
import pytest import pytest

View file

@ -2,7 +2,6 @@
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.cloud_session import CloudUser, get_session from app.cloud_session import CloudUser, get_session

View file

@ -4,7 +4,6 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.cloud_session import CloudUser, get_session from app.cloud_session import CloudUser, get_session

View file

@ -4,16 +4,14 @@ VLM calls are mocked at the service level -- no GPU or API key needed.
""" """
from __future__ import annotations from __future__ import annotations
import io from unittest.mock import MagicMock, patch
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.main import app
from app.cloud_session import get_session from app.cloud_session import get_session
from app.db.session import get_store from app.db.session import get_store
from app.main import app
client = TestClient(app) client = TestClient(app)

View file

@ -1,10 +1,11 @@
import pytest
from fastapi.testclient import TestClient
from unittest.mock import MagicMock from unittest.mock import MagicMock
from app.main import app import pytest
from fastapi.testclient import TestClient
from app.cloud_session import get_session from app.cloud_session import get_session
from app.db.session import get_store from app.db.session import get_store
from app.main import app
client = TestClient(app) client = TestClient(app)

View file

@ -2,7 +2,6 @@
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.cloud_session import CloudUser, get_session from app.cloud_session import CloudUser, get_session

View file

@ -1,6 +1,8 @@
"""Tests for captured_products store methods (kiwi#79).""" """Tests for captured_products store methods (kiwi#79)."""
import pytest
from pathlib import Path from pathlib import Path
import pytest
from app.db.store import Store from app.db.store import Store

View file

@ -1,5 +1,7 @@
import pytest
from pathlib import Path from pathlib import Path
import pytest
from app.db.store import Store from app.db.store import Store

View file

@ -1,5 +1,4 @@
import json, pytest import pytest
from tests.services.recipe.test_element_classifier import store_with_profiles
@pytest.fixture @pytest.fixture

View file

@ -15,8 +15,8 @@ import os
import sys import sys
from pathlib import Path from pathlib import Path
from PIL import Image, ImageOps
import anthropic import anthropic
from PIL import Image, ImageOps
PROMPT = """ PROMPT = """
You are extracting a recipe from a photograph of a recipe card, cookbook page, or handwritten note. You are extracting a recipe from a photograph of a recipe card, cookbook page, or handwritten note.

View file

@ -1,6 +1,6 @@
import pytest
from pathlib import Path
import sys import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parents[2])) sys.path.insert(0, str(Path(__file__).parents[2]))
def test_normalize_ingredient_name(): def test_normalize_ingredient_name():

View file

@ -1,9 +1,7 @@
# tests/services/community/test_ap_compat.py # tests/services/community/test_ap_compat.py
import pytest
import json
from datetime import datetime, timezone from datetime import datetime, timezone
from app.services.community.ap_compat import post_to_ap_json_ld
from app.services.community.ap_compat import post_to_ap_json_ld
POST = { POST = {
"slug": "kiwi-plan-test-pasta-week", "slug": "kiwi-plan-test-pasta-week",

View file

@ -1,8 +1,8 @@
# tests/services/community/test_community_store.py # tests/services/community/test_community_store.py
# MIT License # MIT License
import pytest
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from app.services.community.community_store import KiwiCommunityStore, get_or_create_pseudonym from app.services.community.community_store import KiwiCommunityStore, get_or_create_pseudonym

Some files were not shown because too many files have changed in this diff Show more