fix(lint): add ruff config, fix all lint errors for GitHub CI
- 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
|
|
@ -88,7 +88,8 @@ CF_APP_NAME=kiwi
|
|||
# E2E_TEST_USER_ID=
|
||||
|
||||
# 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_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
||||
|
||||
|
|
|
|||
|
|
@ -4,4 +4,4 @@ Kiwi: Pantry tracking and leftover recipe suggestions.
|
|||
"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__author__ = "Alan 'pyr0ball' Weinstock"
|
||||
__author__ = "Alan 'pyr0ball' Weinstock"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# app/api/endpoints/__init__.py
|
||||
"""
|
||||
API endpoint implementations for Kiwi.
|
||||
"""
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ import logging
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.core.config import settings
|
||||
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:
|
||||
return
|
||||
|
||||
from app.db.store import Store
|
||||
from app.core.config import settings as _settings
|
||||
db_path = _settings.DB_PATH
|
||||
|
||||
|
|
@ -284,6 +282,7 @@ async def get_following():
|
|||
|
||||
def _post_to_ap_note(post, actor, base_url: str) -> dict:
|
||||
from circuitforge_core.activitypub import make_note
|
||||
|
||||
from app.services.community.ap_compat import _build_content
|
||||
|
||||
diet_tags: list[str] = list(getattr(post, "dietary_tags", []) or [])
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from datetime import datetime, timezone
|
|||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
||||
from app.cloud_session import CloudUser, get_session
|
||||
from app.core.config import settings
|
||||
from app.db.store import Store
|
||||
from app.services.community.feed import posts_to_rss
|
||||
|
||||
|
|
@ -36,6 +35,7 @@ def init_community_store(community_db_url: str | None) -> None:
|
|||
)
|
||||
return
|
||||
from circuitforge_core.community import CommunityDB
|
||||
|
||||
from app.services.community.community_store import KiwiCommunityStore
|
||||
db = CommunityDB(dsn=community_db_url)
|
||||
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)
|
||||
from app.core.config import settings as _settings
|
||||
if _settings.AP_ENABLED and session.tier in ("paid", "premium", "ultra"):
|
||||
from circuitforge_core.activitypub import make_create, make_note, PUBLIC
|
||||
from app.services.ap.keys import get_actor
|
||||
from circuitforge_core.activitypub import make_create
|
||||
|
||||
from app.services.ap.delivery import deliver_to_followers
|
||||
from app.services.ap.keys import get_actor
|
||||
_ap_actor = get_actor()
|
||||
if _ap_actor is not None:
|
||||
base = f"https://{_settings.AP_HOST}"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# app/api/endpoints/corrections.py — user corrections to LLM output for SFT training
|
||||
from circuitforge_core.api import make_corrections_router
|
||||
|
||||
from app.db.session import get_db
|
||||
|
||||
router = make_corrections_router(get_db=get_db, product="kiwi")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Feedback router — provided by circuitforge-core."""
|
||||
from circuitforge_core.api import make_feedback_router
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = make_feedback_router(
|
||||
|
|
|
|||
|
|
@ -35,9 +35,12 @@ class AttachResponse(BaseModel):
|
|||
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]:
|
||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
||||
return {"Authorization": f"token {token}"}
|
||||
return {"Authorization": f"token {_forgejo_token()}"}
|
||||
|
||||
|
||||
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
|
||||
so it is visible inline when the issue is viewed.
|
||||
"""
|
||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
||||
token = _forgejo_token()
|
||||
if not token:
|
||||
raise HTTPException(status_code=503, detail="Feedback not configured.")
|
||||
|
||||
|
|
|
|||
|
|
@ -4,15 +4,13 @@ from __future__ import annotations
|
|||
import logging
|
||||
import os
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, get_session
|
||||
from app.services.heimdall_orch import HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN
|
||||
from app.cloud_session import CLOUD_DATA_ROOT, CloudUser, get_session
|
||||
from app.db.store import Store
|
||||
from app.models.schemas.household import (
|
||||
HouseholdAcceptRequest,
|
||||
|
|
@ -24,6 +22,7 @@ from app.models.schemas.household import (
|
|||
HouseholdStatusResponse,
|
||||
MessageResponse,
|
||||
)
|
||||
from app.services.heimdall_orch import HEIMDALL_ADMIN_TOKEN, HEIMDALL_URL
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from __future__ import annotations
|
|||
|
||||
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
|
||||
|
||||
router = APIRouter()
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import asyncio
|
|||
import logging
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
import aiofiles
|
||||
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)
|
||||
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(
|
||||
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)."""
|
||||
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.openfoodfacts import OpenFoodFactsService
|
||||
from app.tiers import can_use
|
||||
|
||||
predictor = ExpirationPredictor()
|
||||
|
|
@ -475,8 +474,8 @@ async def scan_barcode_image(
|
|||
async with aiofiles.open(temp_file, "wb") as f:
|
||||
await f.write(await file.read())
|
||||
from app.services.barcode_scanner import BarcodeScanner
|
||||
from app.services.openfoodfacts import OpenFoodFactsService
|
||||
from app.services.expiration_predictor import ExpirationPredictor
|
||||
from app.services.openfoodfacts import OpenFoodFactsService
|
||||
|
||||
image_bytes = temp_file.read_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
|
||||
highlighted in amber in the UI.
|
||||
"""
|
||||
from app.tiers import can_use
|
||||
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):
|
||||
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
|
||||
the user's inventory.
|
||||
"""
|
||||
from app.tiers import can_use
|
||||
from app.models.schemas.label_capture import LabelConfirmResponse
|
||||
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):
|
||||
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),
|
||||
)
|
||||
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()))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import logging
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
|
@ -42,6 +41,7 @@ async def connect_mastodon(body: dict, session: CloudUser = Depends(get_session)
|
|||
Returns: {"authorize_url": "..."}
|
||||
"""
|
||||
import secrets
|
||||
|
||||
from app.services.ap.mastodon import build_authorize_url, register_app
|
||||
|
||||
instance_url = (body.get("instance_url") or "").strip().rstrip("/")
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from app.cloud_session import CloudUser, get_session
|
|||
from app.db.session import get_store
|
||||
from app.db.store import Store
|
||||
from app.models.schemas.meal_plan import (
|
||||
VALID_MEAL_TYPES,
|
||||
CreatePlanRequest,
|
||||
GapItem,
|
||||
PlanSummary,
|
||||
|
|
@ -22,7 +23,6 @@ from app.models.schemas.meal_plan import (
|
|||
UpdatePlanRequest,
|
||||
UpdatePrepTaskRequest,
|
||||
UpsertSlotRequest,
|
||||
VALID_MEAL_TYPES,
|
||||
)
|
||||
from app.services.meal_plan.affiliates import get_retailer_links
|
||||
from app.services.meal_plan.prep_scheduler import build_prep_tasks
|
||||
|
|
|
|||
|
|
@ -15,9 +15,9 @@ from app.core.config import settings
|
|||
from app.db.session import get_store
|
||||
from app.db.store import Store
|
||||
from app.models.schemas.receipt import (
|
||||
ApprovedInventoryItem,
|
||||
ApproveOCRRequest,
|
||||
ApproveOCRResponse,
|
||||
ApprovedInventoryItem,
|
||||
)
|
||||
from app.services.expiration_predictor import ExpirationPredictor
|
||||
from app.tiers import can_use
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ from app.cloud_session import CloudUser, get_session
|
|||
from app.core.config import settings
|
||||
from app.db.session import get_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.receipt import ReceiptResponse
|
||||
from app.tiers import can_use
|
||||
|
||||
router = APIRouter()
|
||||
|
|
|
|||
|
|
@ -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)})
|
||||
except RuntimeError as 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")
|
||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": "Scan failed unexpectedly."})
|
||||
|
||||
|
|
|
|||
|
|
@ -114,7 +114,6 @@ async def submit_recipe_tag(
|
|||
_validate_location(body.domain, body.category, body.subcategory)
|
||||
|
||||
try:
|
||||
import psycopg2.errors # type: ignore[import]
|
||||
row = store.submit_recipe_tag(
|
||||
recipe_id=body.recipe_id,
|
||||
domain=body.domain,
|
||||
|
|
|
|||
|
|
@ -2,23 +2,24 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json as _json_mod
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
import json as _json_mod
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
from app.api.endpoints.imitate import _build_recipe_prompt
|
||||
from app.db.session import get_store
|
||||
from app.db.store import Store
|
||||
from app.models.schemas.recipe import (
|
||||
AskRecipeHit,
|
||||
AskRequest,
|
||||
AskResponse,
|
||||
AskRecipeHit,
|
||||
AssemblyTemplateOut,
|
||||
BuildRequest,
|
||||
LeftoversResponse,
|
||||
|
|
@ -31,7 +32,7 @@ from app.models.schemas.recipe import (
|
|||
StreamTokenResponse,
|
||||
)
|
||||
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 (
|
||||
build_from_selection,
|
||||
get_role_candidates,
|
||||
|
|
@ -47,9 +48,8 @@ from app.services.recipe.browser_domains import (
|
|||
get_subcategory_names,
|
||||
)
|
||||
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.heimdall_orch import check_orch_budget
|
||||
from app.services.recipe.time_effort import parse_time_effort
|
||||
from app.tiers import can_use
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -149,7 +149,9 @@ async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
|
|||
"""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.cloud_session import CLOUD_MODE
|
||||
from app.tasks.runner import insert_task
|
||||
|
||||
|
|
@ -495,7 +497,7 @@ async def browse_recipes(
|
|||
"community_tagged": True,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("community tag fallback failed: %s", exc)
|
||||
log.warning("community tag fallback failed: %s", exc)
|
||||
|
||||
store.log_browser_telemetry(
|
||||
domain=domain,
|
||||
|
|
@ -576,7 +578,8 @@ async def build_recipe(
|
|||
return None
|
||||
# Persist to recipes table so the result can be saved/bookmarked.
|
||||
# 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(
|
||||
_js.dumps(req.role_overrides, sort_keys=True).encode()
|
||||
).hexdigest()[:8]
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ def _enrich(item: dict, builder: GroceryLinkBuilder) -> ShoppingItemResponse:
|
|||
links = builder.build_links(item["name"])
|
||||
return ShoppingItemResponse(
|
||||
**{**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],
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
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.corrections import router as corrections_router
|
||||
from app.api.endpoints.mastodon_oauth import router as mastodon_router
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@ import os
|
|||
from dataclasses import dataclass
|
||||
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
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
"""
|
||||
Core components for Kiwi.
|
||||
Contains configuration, dependencies, and other core functionality.
|
||||
"""
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -4,28 +4,28 @@ This file is kept for historical reference only. Nothing imports it.
|
|||
"""
|
||||
# fmt: off # noqa — dead file, not linted
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
Column,
|
||||
String,
|
||||
Text,
|
||||
Boolean,
|
||||
Numeric,
|
||||
DateTime,
|
||||
Date,
|
||||
ForeignKey,
|
||||
CheckConstraint,
|
||||
Column,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Numeric,
|
||||
String,
|
||||
Table,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
# Association table for many-to-many relationship between products and tags
|
||||
product_tags = Table(
|
||||
"product_tags",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ from typing import Any
|
|||
|
||||
from circuitforge_core.db.base import get_connection
|
||||
from circuitforge_core.db.migrations import run_migrations
|
||||
|
||||
from app.services.recipe.sensory import SensoryExclude, passes_sensory_filter
|
||||
|
||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||
|
|
@ -304,6 +305,7 @@ class Store:
|
|||
Returns (updated_count, skipped_count).
|
||||
"""
|
||||
from datetime import date
|
||||
|
||||
from app.services.expiration_predictor import ExpirationPredictor
|
||||
|
||||
predictor = ExpirationPredictor()
|
||||
|
|
@ -573,13 +575,26 @@ class Store:
|
|||
if pattern in lower:
|
||||
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:
|
||||
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
|
||||
if token not in terms:
|
||||
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
|
||||
if token in Store._FTS_SYNONYMS:
|
||||
canonical = Store._FTS_SYNONYMS[token]
|
||||
|
|
|
|||
|
|
@ -73,7 +73,9 @@ async def lifespan(app: FastAPI):
|
|||
try:
|
||||
from app.db.store import _COUNT_CACHE
|
||||
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):
|
||||
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
|
||||
from app.api.endpoints.activitypub import ap_router, webfinger_router
|
||||
|
||||
app.include_router(webfinger_router)
|
||||
app.include_router(ap_router)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
"""
|
||||
Data models for Kiwi.
|
||||
Contains domain models and Pydantic schemas.
|
||||
"""
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
"""
|
||||
Domain models for Kiwi.
|
||||
These represent the core business entities.
|
||||
"""
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from app.models.schemas.receipt import ReceiptResponse
|
||||
from app.models.schemas.quality import QualityAssessment
|
||||
from app.models.schemas.receipt import ReceiptResponse
|
||||
|
||||
__all__ = ["ReceiptResponse", "QualityAssessment"]
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from datetime import date
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Tags ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TagCreate(BaseModel):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ from datetime import date as _date
|
|||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@
|
|||
Pydantic schemas for OCR data models.
|
||||
"""
|
||||
|
||||
from datetime import datetime, date, time
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import date, datetime, time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from uuid import UUID
|
||||
from pydantic import BaseModel, Field, validator
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MerchantInfo(BaseModel):
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ from __future__ import annotations
|
|||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
# ── Ingredient in a scanned recipe ────────────────────────────────────────────
|
||||
|
||||
class ScannedIngredientSchema(BaseModel):
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ from images (UPC, EAN, QR codes, etc.).
|
|||
"""
|
||||
|
||||
import io
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from pyzbar import pyzbar
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional
|
||||
import logging
|
||||
|
||||
try:
|
||||
from PIL import Image as _PILImage
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ Fallback path: LLMRouter — only fires for unknown products when tier allows it
|
|||
import logging
|
||||
import re
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional, List
|
||||
from typing import List, Optional
|
||||
|
||||
from circuitforge_core.llm.router import LLMRouter
|
||||
|
||||
from app.tiers import can_use
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
|
|||
|
|
@ -6,13 +6,12 @@ This module provides functionality to convert receipt and quality assessment
|
|||
data into spreadsheet formats for easy viewing and analysis.
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from app.models.schemas.receipt import ReceiptResponse
|
||||
from app.models.schemas.quality import QualityAssessment
|
||||
from app.models.schemas.receipt import ReceiptResponse
|
||||
|
||||
|
||||
class SpreadsheetExporter:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ Image preprocessing services for Kiwi.
|
|||
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 enhance_image, correct_perspective
|
||||
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,
|
||||
)
|
||||
|
||||
__all__ = ["convert_to_standard_format", "extract_metadata", "enhance_image", "correct_perspective"]
|
||||
__all__ = ["convert_to_standard_format", "extract_metadata", "enhance_image", "correct_perspective"]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
#!/usr/bin/env python
|
||||
# app/services/image_preprocessing/
|
||||
import cv2
|
||||
import numpy as np
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -29,19 +30,19 @@ def enhance_image(
|
|||
try:
|
||||
# Check if CUDA is available
|
||||
use_cuda = cv2.cuda.getCudaEnabledDeviceCount() > 0
|
||||
|
||||
|
||||
# Set output path if not provided
|
||||
if output_path is None:
|
||||
output_path = image_path.with_stem(f"{image_path.stem}_enhanced")
|
||||
|
||||
|
||||
# Read image
|
||||
img = cv2.imread(str(image_path))
|
||||
if img is None:
|
||||
return False, f"Failed to read image: {image_path}", None
|
||||
|
||||
|
||||
# Convert to grayscale
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
|
||||
# Apply denoising if requested
|
||||
if denoise:
|
||||
if use_cuda:
|
||||
|
|
@ -55,29 +56,29 @@ def enhance_image(
|
|||
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
|
||||
else:
|
||||
denoised = gray
|
||||
|
||||
|
||||
# Apply adaptive thresholding if requested
|
||||
if adaptive_threshold:
|
||||
# Adaptive thresholding works well for receipts with varying backgrounds
|
||||
binary = cv2.adaptiveThreshold(
|
||||
denoised,
|
||||
255,
|
||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY,
|
||||
11,
|
||||
denoised,
|
||||
255,
|
||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY,
|
||||
11,
|
||||
2
|
||||
)
|
||||
processed = binary
|
||||
else:
|
||||
processed = denoised
|
||||
|
||||
|
||||
# Write enhanced image
|
||||
success = cv2.imwrite(str(output_path), processed)
|
||||
if not success:
|
||||
return False, f"Failed to write enhanced image to {output_path}", None
|
||||
|
||||
|
||||
return True, "Image enhanced successfully", output_path
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error enhancing image: {e}")
|
||||
return False, f"Error enhancing image: {str(e)}", None
|
||||
|
|
@ -100,40 +101,40 @@ def correct_perspective(
|
|||
# Set output path if not provided
|
||||
if output_path is None:
|
||||
output_path = image_path.with_stem(f"{image_path.stem}_perspective")
|
||||
|
||||
|
||||
# Read image
|
||||
img = cv2.imread(str(image_path))
|
||||
if img is None:
|
||||
return False, f"Failed to read image: {image_path}", None
|
||||
|
||||
|
||||
# Convert to grayscale
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
|
||||
# Apply Gaussian blur to reduce noise
|
||||
blur = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||
|
||||
|
||||
# Apply edge detection
|
||||
edges = cv2.Canny(blur, 50, 150, apertureSize=3)
|
||||
|
||||
|
||||
# Find contours
|
||||
contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
|
||||
# Find the largest contour by area which is likely the receipt
|
||||
if not contours:
|
||||
return False, "No contours found in image", None
|
||||
|
||||
|
||||
largest_contour = max(contours, key=cv2.contourArea)
|
||||
|
||||
|
||||
# Approximate the contour to get the corners
|
||||
epsilon = 0.02 * cv2.arcLength(largest_contour, True)
|
||||
approx = cv2.approxPolyDP(largest_contour, epsilon, True)
|
||||
|
||||
|
||||
# If we have a quadrilateral, we can apply perspective transform
|
||||
if len(approx) == 4:
|
||||
# Sort the points for the perspective transform
|
||||
# This is a simplified implementation
|
||||
src_pts = approx.reshape(4, 2).astype(np.float32)
|
||||
|
||||
|
||||
# Get width and height for the destination image
|
||||
width = int(max(
|
||||
np.linalg.norm(src_pts[0] - src_pts[1]),
|
||||
|
|
@ -143,7 +144,7 @@ def correct_perspective(
|
|||
np.linalg.norm(src_pts[0] - src_pts[3]),
|
||||
np.linalg.norm(src_pts[1] - src_pts[2])
|
||||
))
|
||||
|
||||
|
||||
# Define destination points
|
||||
dst_pts = np.array([
|
||||
[0, 0],
|
||||
|
|
@ -151,22 +152,22 @@ def correct_perspective(
|
|||
[width - 1, height - 1],
|
||||
[0, height - 1]
|
||||
], dtype=np.float32)
|
||||
|
||||
|
||||
# Get perspective transform matrix
|
||||
M = cv2.getPerspectiveTransform(src_pts, dst_pts)
|
||||
|
||||
|
||||
# Apply perspective transform
|
||||
warped = cv2.warpPerspective(img, M, (width, height))
|
||||
|
||||
|
||||
# Write corrected image
|
||||
success = cv2.imwrite(str(output_path), warped)
|
||||
if not success:
|
||||
return False, f"Failed to write perspective-corrected image to {output_path}", None
|
||||
|
||||
|
||||
return True, "Perspective corrected successfully", output_path
|
||||
else:
|
||||
return False, "Receipt corners not clearly detected", None
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error correcting perspective: {e}")
|
||||
return False, f"Error correcting perspective: {str(e)}", None
|
||||
return False, f"Error correcting perspective: {str(e)}", None
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
#!/usr/bin/env python
|
||||
# app/services/image_preprocessing/format_conversion.py
|
||||
import cv2
|
||||
import numpy as np
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional
|
||||
from typing import Optional, Tuple
|
||||
|
||||
import cv2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -28,33 +28,33 @@ def convert_to_standard_format(
|
|||
# Check if CUDA is available and set up GPU processing
|
||||
if cv2.cuda.getCudaEnabledDeviceCount() > 0:
|
||||
logger.info("CUDA available, using GPU acceleration")
|
||||
use_cuda = True
|
||||
use_cuda = True # noqa: F841
|
||||
else:
|
||||
logger.info("CUDA not available, using CPU processing")
|
||||
use_cuda = False
|
||||
|
||||
use_cuda = False # noqa: F841
|
||||
|
||||
# Read image
|
||||
img = cv2.imread(str(image_path))
|
||||
if img is None:
|
||||
return False, f"Failed to read image: {image_path}", None
|
||||
|
||||
|
||||
# If PDF, extract first page (simplified for Phase 1)
|
||||
if image_path.suffix.lower() == '.pdf':
|
||||
# This is a placeholder for PDF handling
|
||||
# In a real implementation, you'd use a PDF processing library
|
||||
return False, "PDF processing not implemented in Phase 1", None
|
||||
|
||||
|
||||
# Set output path if not provided
|
||||
if output_path is None:
|
||||
output_path = image_path.with_suffix(f".{target_format}")
|
||||
|
||||
|
||||
# Write converted image
|
||||
success = cv2.imwrite(str(output_path), img)
|
||||
if not success:
|
||||
return False, f"Failed to write converted image to {output_path}", None
|
||||
|
||||
|
||||
return True, "Image converted successfully", output_path
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error converting image: {e}")
|
||||
return False, f"Error converting image: {str(e)}", None
|
||||
|
|
@ -74,7 +74,7 @@ def extract_metadata(image_path: Path) -> dict:
|
|||
"original_format": image_path.suffix.lstrip(".").lower(),
|
||||
"file_size_bytes": image_path.stat().st_size,
|
||||
}
|
||||
|
||||
|
||||
try:
|
||||
img = cv2.imread(str(image_path))
|
||||
if img is not None:
|
||||
|
|
@ -85,5 +85,5 @@ def extract_metadata(image_path: Path) -> dict:
|
|||
})
|
||||
except Exception as e:
|
||||
logger.exception(f"Error extracting image metadata: {e}")
|
||||
|
||||
return metadata
|
||||
|
||||
return metadata
|
||||
|
|
|
|||
|
|
@ -8,31 +8,31 @@ This service orchestrates:
|
|||
- Tag management
|
||||
"""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from typing import List, Optional, Dict, Any
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
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 (
|
||||
ProductCreate,
|
||||
ProductUpdate,
|
||||
ProductResponse,
|
||||
InventoryItemCreate,
|
||||
InventoryItemUpdate,
|
||||
InventoryItemResponse,
|
||||
TagCreate,
|
||||
TagResponse,
|
||||
InventoryItemUpdate,
|
||||
InventoryStats,
|
||||
ProductCreate,
|
||||
ProductResponse,
|
||||
ProductUpdate,
|
||||
TagCreate,
|
||||
)
|
||||
from app.services.barcode_scanner import BarcodeScanner
|
||||
from app.services.openfoodfacts import OpenFoodFactsService
|
||||
from app.services.expiration_predictor import ExpirationPredictor
|
||||
from app.services.openfoodfacts import OpenFoodFactsService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from __future__ import annotations
|
|||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from urllib.parse import quote_plus
|
|||
|
||||
from circuitforge_core.affiliates import AffiliateProgram, register_program, wrap_url
|
||||
|
||||
|
||||
# ── URL builders ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _walmart_search(url: str, affiliate_id: str) -> str:
|
||||
|
|
|
|||
|
|
@ -10,17 +10,13 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from PIL import Image
|
||||
import torch
|
||||
from transformers import (
|
||||
Qwen2VLForConditionalGeneration,
|
||||
AutoProcessor,
|
||||
BitsAndBytesConfig
|
||||
)
|
||||
from PIL import Image
|
||||
from transformers import AutoProcessor, BitsAndBytesConfig, Qwen2VLForConditionalGeneration
|
||||
|
||||
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.
|
||||
try:
|
||||
from app.services.task_inference import task_allocate, TaskNotRegistered
|
||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||
from app.services.task_inference import TaskNotRegistered, task_allocate
|
||||
try:
|
||||
with task_allocate(
|
||||
"kiwi", "ocr",
|
||||
|
|
@ -57,6 +53,7 @@ def _try_docuvision(image_path: str | Path) -> str | None:
|
|||
# Tier 2: direct allocation — hardcoded service type.
|
||||
try:
|
||||
from circuitforge_orch.client import CFOrchClient
|
||||
|
||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||
|
||||
client = CFOrchClient(cf_orch_url)
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@ This module provides functionality to look up product information
|
|||
from the OpenFoodFacts database using barcodes (UPC/EAN).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
from app.core.config import settings
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ Contains functionality for evaluating receipt image quality.
|
|||
|
||||
from app.services.quality.assessment import QualityAssessor
|
||||
|
||||
__all__ = ["QualityAssessor"]
|
||||
__all__ = ["QualityAssessor"]
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
#!/usr/bin/env python
|
||||
# app/services/quality/assessment.py
|
||||
import cv2
|
||||
import numpy as np
|
||||
import logging
|
||||
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__)
|
||||
|
||||
|
|
@ -12,7 +13,7 @@ class QualityAssessor:
|
|||
"""
|
||||
Assesses the quality of receipt images for processing suitability.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self, min_quality_score: float = 50.0):
|
||||
"""
|
||||
Initialize the quality assessor.
|
||||
|
|
@ -21,7 +22,7 @@ class QualityAssessor:
|
|||
min_quality_score: Minimum acceptable quality score (0-100)
|
||||
"""
|
||||
self.min_quality_score = min_quality_score
|
||||
|
||||
|
||||
def assess_image(self, image_path: Path) -> Dict[str, Any]:
|
||||
"""
|
||||
Assess the quality of an image.
|
||||
|
|
@ -41,19 +42,19 @@ class QualityAssessor:
|
|||
"error": f"Failed to read image: {image_path}",
|
||||
"overall_score": 0.0,
|
||||
}
|
||||
|
||||
|
||||
# Convert to grayscale for some metrics
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
||||
|
||||
|
||||
# Calculate various quality metrics
|
||||
blur_score = self._calculate_blur_score(gray)
|
||||
lighting_score = self._calculate_lighting_score(gray)
|
||||
contrast_score = self._calculate_contrast_score(gray)
|
||||
size_score = self._calculate_size_score(img.shape)
|
||||
|
||||
|
||||
# Check for potential fold lines
|
||||
fold_detected, fold_severity = self._detect_folds(gray)
|
||||
|
||||
|
||||
# Calculate overall quality score
|
||||
overall_score = self._calculate_overall_score({
|
||||
"blur": blur_score,
|
||||
|
|
@ -62,7 +63,7 @@ class QualityAssessor:
|
|||
"size": size_score,
|
||||
"fold": 100.0 if not fold_detected else (100.0 - fold_severity * 20.0)
|
||||
})
|
||||
|
||||
|
||||
# Create assessment result
|
||||
result = {
|
||||
"success": True,
|
||||
|
|
@ -85,9 +86,9 @@ class QualityAssessor:
|
|||
"fold_severity": fold_severity if fold_detected else 0.0,
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
return result
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error assessing image quality: {e}")
|
||||
return {
|
||||
|
|
@ -95,7 +96,7 @@ class QualityAssessor:
|
|||
"error": f"Error assessing image quality: {str(e)}",
|
||||
"overall_score": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def _calculate_blur_score(self, gray_img: np.ndarray) -> float:
|
||||
"""
|
||||
Calculate blur score using Laplacian variance.
|
||||
|
|
@ -109,10 +110,10 @@ class QualityAssessor:
|
|||
"""
|
||||
# Use Laplacian for edge detection
|
||||
laplacian = cv2.Laplacian(gray_img, cv2.CV_64F)
|
||||
|
||||
|
||||
# Calculate variance of Laplacian
|
||||
variance = laplacian.var()
|
||||
|
||||
|
||||
# Map variance to a 0-100 score
|
||||
# These thresholds might need adjustment based on your specific requirements
|
||||
if variance < 10:
|
||||
|
|
@ -123,7 +124,7 @@ class QualityAssessor:
|
|||
return 50 + (variance - 100) / 900 * 50 # Map 100-1000 to 50-100
|
||||
else:
|
||||
return 100.0 # Very sharp
|
||||
|
||||
|
||||
def _calculate_lighting_score(self, gray_img: np.ndarray) -> float:
|
||||
"""
|
||||
Calculate lighting score based on average brightness and std dev.
|
||||
|
|
@ -136,21 +137,21 @@ class QualityAssessor:
|
|||
"""
|
||||
# Calculate mean brightness
|
||||
mean = gray_img.mean()
|
||||
|
||||
|
||||
# Calculate standard deviation of brightness
|
||||
std = gray_img.std()
|
||||
|
||||
|
||||
# Ideal mean would be around 127 (middle of 0-255)
|
||||
# Penalize if too dark or too bright
|
||||
mean_score = 100 - abs(mean - 127) / 127 * 100
|
||||
|
||||
|
||||
# Higher std dev generally means better contrast
|
||||
# But we'll cap at 60 for reasonable balance
|
||||
std_score = min(std / 60 * 100, 100)
|
||||
|
||||
|
||||
# Combine scores (weighted)
|
||||
return 0.6 * mean_score + 0.4 * std_score
|
||||
|
||||
|
||||
def _calculate_contrast_score(self, gray_img: np.ndarray) -> float:
|
||||
"""
|
||||
Calculate contrast score.
|
||||
|
|
@ -163,13 +164,13 @@ class QualityAssessor:
|
|||
"""
|
||||
# Calculate histogram
|
||||
hist = cv2.calcHist([gray_img], [0], None, [256], [0, 256])
|
||||
|
||||
|
||||
# Calculate percentage of pixels in each brightness range
|
||||
total_pixels = gray_img.shape[0] * gray_img.shape[1]
|
||||
dark_pixels = np.sum(hist[:50]) / total_pixels
|
||||
mid_pixels = np.sum(hist[50:200]) / total_pixels
|
||||
bright_pixels = np.sum(hist[200:]) / total_pixels
|
||||
|
||||
|
||||
# Ideal: good distribution across ranges with emphasis on mid-range
|
||||
# This is a simplified model - real receipts may need different distributions
|
||||
score = (
|
||||
|
|
@ -177,9 +178,9 @@ class QualityAssessor:
|
|||
(0.6 * min(mid_pixels * 200, 100)) + # Want many mid pixels
|
||||
(0.2 * min(bright_pixels * 500, 100)) # Want some bright pixels (background)
|
||||
)
|
||||
|
||||
|
||||
return score
|
||||
|
||||
|
||||
def _calculate_size_score(self, shape: Tuple[int, int, int]) -> float:
|
||||
"""
|
||||
Calculate score based on image dimensions.
|
||||
|
|
@ -191,10 +192,10 @@ class QualityAssessor:
|
|||
Size score (0-100)
|
||||
"""
|
||||
height, width = shape[0], shape[1]
|
||||
|
||||
|
||||
# Minimum recommended dimensions for good OCR
|
||||
min_height, min_width = 800, 600
|
||||
|
||||
|
||||
# Calculate size score
|
||||
if height < min_height or width < min_width:
|
||||
# Penalize if below minimum dimensions
|
||||
|
|
@ -202,7 +203,7 @@ class QualityAssessor:
|
|||
else:
|
||||
# Full score if dimensions are adequate
|
||||
return 100.0
|
||||
|
||||
|
||||
def _detect_folds(self, gray_img: np.ndarray) -> Tuple[bool, float]:
|
||||
"""
|
||||
Detect potential fold lines in the image.
|
||||
|
|
@ -216,37 +217,37 @@ class QualityAssessor:
|
|||
"""
|
||||
# Apply edge detection
|
||||
edges = cv2.Canny(gray_img, 50, 150, apertureSize=3)
|
||||
|
||||
|
||||
# Apply Hough Line Transform to detect straight lines
|
||||
lines = cv2.HoughLinesP(
|
||||
edges,
|
||||
rho=1,
|
||||
theta=np.pi/180,
|
||||
threshold=100,
|
||||
edges,
|
||||
rho=1,
|
||||
theta=np.pi/180,
|
||||
threshold=100,
|
||||
minLineLength=gray_img.shape[1] // 3, # Look for lines at least 1/3 of image width
|
||||
maxLineGap=10
|
||||
)
|
||||
|
||||
|
||||
if lines is None:
|
||||
return False, 0.0
|
||||
|
||||
|
||||
# Check for horizontal or vertical lines that could be folds
|
||||
potential_folds = []
|
||||
height, width = gray_img.shape
|
||||
|
||||
|
||||
for line in lines:
|
||||
x1, y1, x2, y2 = line[0]
|
||||
length = np.sqrt((x2 - x1)**2 + (y2 - y1)**2)
|
||||
angle = np.abs(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi)
|
||||
|
||||
|
||||
# Check if horizontal (0±10°) or vertical (90±10°)
|
||||
is_horizontal = angle < 10 or angle > 170
|
||||
is_vertical = abs(angle - 90) < 10
|
||||
|
||||
|
||||
# Check if length is significant
|
||||
is_significant = (is_horizontal and length > width * 0.5) or \
|
||||
(is_vertical and length > height * 0.5)
|
||||
|
||||
|
||||
if (is_horizontal or is_vertical) and is_significant:
|
||||
# Calculate intensity variance along the line
|
||||
# This helps determine if it's a fold (sharp brightness change)
|
||||
|
|
@ -255,11 +256,11 @@ class QualityAssessor:
|
|||
"length": length,
|
||||
"is_horizontal": is_horizontal,
|
||||
})
|
||||
|
||||
|
||||
# Determine if folds are detected and their severity
|
||||
if not potential_folds:
|
||||
return False, 0.0
|
||||
|
||||
|
||||
# Severity based on number and length of potential folds
|
||||
# This is a simplified metric for Phase 1
|
||||
total_len = sum(fold["length"] for fold in potential_folds)
|
||||
|
|
@ -267,9 +268,9 @@ class QualityAssessor:
|
|||
severity = min(5.0, total_len / width * 2.5)
|
||||
else:
|
||||
severity = min(5.0, total_len / height * 2.5)
|
||||
|
||||
|
||||
return True, severity
|
||||
|
||||
|
||||
def _calculate_overall_score(self, scores: Dict[str, float]) -> float:
|
||||
"""
|
||||
Calculate overall quality score from individual metrics.
|
||||
|
|
@ -288,12 +289,12 @@ class QualityAssessor:
|
|||
"size": 0.10,
|
||||
"fold": 0.10,
|
||||
}
|
||||
|
||||
|
||||
# Calculate weighted average
|
||||
overall = sum(weights[key] * scores[key] for key in weights)
|
||||
|
||||
|
||||
return overall
|
||||
|
||||
|
||||
def _generate_suggestions(self, metrics: Dict[str, Any]) -> list:
|
||||
"""
|
||||
Generate improvement suggestions based on metrics.
|
||||
|
|
@ -305,28 +306,28 @@ class QualityAssessor:
|
|||
List of improvement suggestions
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
|
||||
# Blur suggestions
|
||||
if metrics["blur"] < 60:
|
||||
suggestions.append("Hold the camera steady and ensure the receipt is in focus.")
|
||||
|
||||
|
||||
# Lighting suggestions
|
||||
if metrics["lighting"] < 60:
|
||||
suggestions.append("Improve lighting conditions and avoid shadows on the receipt.")
|
||||
|
||||
|
||||
# Contrast suggestions
|
||||
if metrics["contrast"] < 60:
|
||||
suggestions.append("Ensure good contrast between text and background.")
|
||||
|
||||
|
||||
# Size suggestions
|
||||
if metrics["size"] < 60:
|
||||
suggestions.append("Move the camera closer to the receipt for better resolution.")
|
||||
|
||||
|
||||
# Fold suggestions
|
||||
if metrics["fold"]:
|
||||
if metrics["fold_severity"] > 3.0:
|
||||
suggestions.append("The receipt has severe folds. Try to flatten it before capturing.")
|
||||
else:
|
||||
suggestions.append("Flatten the receipt to remove fold lines for better processing.")
|
||||
|
||||
return suggestions
|
||||
|
||||
return suggestions
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
#!/usr/bin/env python
|
||||
# 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 sys
|
||||
from app.utils.progress import ProgressIndicator
|
||||
import uuid
|
||||
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.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__)
|
||||
|
||||
|
|
@ -25,7 +24,7 @@ class ReceiptService:
|
|||
"""
|
||||
Service for handling receipt processing.
|
||||
"""
|
||||
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the receipt service.
|
||||
|
|
@ -33,18 +32,18 @@ class ReceiptService:
|
|||
self.quality_assessor = QualityAssessor()
|
||||
self.upload_dir = Path(settings.UPLOAD_DIR)
|
||||
self.processing_dir = Path(settings.PROCESSING_DIR)
|
||||
|
||||
|
||||
# Create directories if they don't exist
|
||||
self.upload_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.processing_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# In-memory storage for Phase 1 (would be replaced by DB in production)
|
||||
self.receipts = {}
|
||||
self.quality_assessments = {}
|
||||
|
||||
|
||||
async def process_receipt(
|
||||
self,
|
||||
file: UploadFile,
|
||||
self,
|
||||
file: UploadFile,
|
||||
background_tasks: BackgroundTasks
|
||||
) -> ReceiptResponse:
|
||||
"""
|
||||
|
|
@ -59,11 +58,11 @@ class ReceiptService:
|
|||
"""
|
||||
# Generate unique ID for receipt
|
||||
receipt_id = str(uuid.uuid4())
|
||||
|
||||
|
||||
# Save uploaded file
|
||||
upload_path = self.upload_dir / f"{receipt_id}_{file.filename}"
|
||||
await self._save_upload_file(file, upload_path)
|
||||
|
||||
|
||||
# Create receipt entry
|
||||
receipt = {
|
||||
"id": receipt_id,
|
||||
|
|
@ -73,16 +72,16 @@ class ReceiptService:
|
|||
"processed_path": None,
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
|
||||
self.receipts[receipt_id] = receipt
|
||||
|
||||
|
||||
# Add background task for processing
|
||||
background_tasks.add_task(
|
||||
self._process_receipt_background,
|
||||
receipt_id,
|
||||
upload_path
|
||||
)
|
||||
|
||||
|
||||
return ReceiptResponse(
|
||||
id=receipt_id,
|
||||
filename=file.filename,
|
||||
|
|
@ -90,7 +89,7 @@ class ReceiptService:
|
|||
metadata={},
|
||||
quality_score=None,
|
||||
)
|
||||
|
||||
|
||||
async def get_receipt(self, receipt_id: str) -> Optional[ReceiptResponse]:
|
||||
"""
|
||||
Get receipt by ID.
|
||||
|
|
@ -104,10 +103,10 @@ class ReceiptService:
|
|||
receipt = self.receipts.get(receipt_id)
|
||||
if not receipt:
|
||||
return None
|
||||
|
||||
|
||||
quality = self.quality_assessments.get(receipt_id)
|
||||
quality_score = quality.get("overall_score") if quality else None
|
||||
|
||||
|
||||
return ReceiptResponse(
|
||||
id=receipt["id"],
|
||||
filename=receipt["filename"],
|
||||
|
|
@ -115,7 +114,7 @@ class ReceiptService:
|
|||
metadata=receipt["metadata"],
|
||||
quality_score=quality_score,
|
||||
)
|
||||
|
||||
|
||||
async def get_receipt_quality(self, receipt_id: str) -> Optional[QualityAssessment]:
|
||||
"""
|
||||
Get quality assessment for a receipt.
|
||||
|
|
@ -129,7 +128,7 @@ class ReceiptService:
|
|||
quality = self.quality_assessments.get(receipt_id)
|
||||
if not quality:
|
||||
return None
|
||||
|
||||
|
||||
return QualityAssessment(
|
||||
receipt_id=receipt_id,
|
||||
overall_score=quality["overall_score"],
|
||||
|
|
@ -193,11 +192,11 @@ class ReceiptService:
|
|||
while content:
|
||||
await out_file.write(content)
|
||||
content = await file.read(1024 * 1024)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Error saving upload file: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Error saving upload file: {str(e)}")
|
||||
|
||||
|
||||
async def _process_receipt_background(self, receipt_id: str, upload_path: Path) -> None:
|
||||
"""
|
||||
Background task for processing a receipt with progress indicators.
|
||||
|
|
@ -209,65 +208,65 @@ class ReceiptService:
|
|||
try:
|
||||
# Print a message to indicate start of processing
|
||||
print(f"\nProcessing receipt {receipt_id}...")
|
||||
|
||||
|
||||
# Update status
|
||||
self.receipts[receipt_id]["status"] = "processing"
|
||||
|
||||
|
||||
# Create processing directory for this receipt
|
||||
receipt_dir = self.processing_dir / receipt_id
|
||||
receipt_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# Step 1: Convert to standard format
|
||||
print(" Step 1/4: Converting to standard format...")
|
||||
converted_path = receipt_dir / f"{receipt_id}_converted.png"
|
||||
success, message, actual_converted_path = convert_to_standard_format(
|
||||
upload_path,
|
||||
upload_path,
|
||||
converted_path
|
||||
)
|
||||
|
||||
|
||||
if not success:
|
||||
print(f" ✗ Format conversion failed: {message}")
|
||||
self.receipts[receipt_id]["status"] = "error"
|
||||
self.receipts[receipt_id]["error"] = message
|
||||
return
|
||||
print(" ✓ Format conversion complete")
|
||||
|
||||
|
||||
# Step 2: Correct perspective
|
||||
print(" Step 2/4: Correcting perspective...")
|
||||
perspective_path = receipt_dir / f"{receipt_id}_perspective.png"
|
||||
success, message, actual_perspective_path = correct_perspective(
|
||||
actual_converted_path,
|
||||
actual_converted_path,
|
||||
perspective_path
|
||||
)
|
||||
|
||||
|
||||
# Use corrected image if available, otherwise use converted image
|
||||
current_path = actual_perspective_path if success else actual_converted_path
|
||||
if success:
|
||||
print(" ✓ Perspective correction complete")
|
||||
else:
|
||||
print(f" ⚠ Perspective correction skipped: {message}")
|
||||
|
||||
|
||||
# Step 3: Enhance image
|
||||
print(" Step 3/4: Enhancing image...")
|
||||
enhanced_path = receipt_dir / f"{receipt_id}_enhanced.png"
|
||||
success, message, actual_enhanced_path = enhance_image(
|
||||
current_path,
|
||||
current_path,
|
||||
enhanced_path
|
||||
)
|
||||
|
||||
|
||||
if not success:
|
||||
print(f" ⚠ Enhancement warning: {message}")
|
||||
# Continue with current path
|
||||
else:
|
||||
current_path = actual_enhanced_path
|
||||
print(" ✓ Image enhancement complete")
|
||||
|
||||
|
||||
# Step 4: Assess quality
|
||||
print(" Step 4/4: Assessing quality...")
|
||||
quality_assessment = self.quality_assessor.assess_image(current_path)
|
||||
self.quality_assessments[receipt_id] = quality_assessment
|
||||
print(f" ✓ Quality assessment complete: score {quality_assessment['overall_score']:.1f}/100")
|
||||
|
||||
|
||||
# Step 5: Extract metadata
|
||||
print(" Extracting metadata...")
|
||||
metadata = extract_metadata(upload_path)
|
||||
|
|
@ -279,17 +278,17 @@ class ReceiptService:
|
|||
"format": processed_metadata.get("original_format"),
|
||||
}
|
||||
print(" ✓ Metadata extraction complete")
|
||||
|
||||
|
||||
# Update receipt entry
|
||||
self.receipts[receipt_id].update({
|
||||
"status": "processed",
|
||||
"processed_path": str(current_path),
|
||||
"metadata": metadata,
|
||||
})
|
||||
|
||||
|
||||
print(f"✓ Receipt {receipt_id} processed successfully!")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Error processing receipt {receipt_id}: {e}")
|
||||
self.receipts[receipt_id]["status"] = "error"
|
||||
self.receipts[receipt_id]["error"] = str(e)
|
||||
self.receipts[receipt_id]["error"] = str(e)
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ from dataclasses import dataclass
|
|||
|
||||
from app.models.schemas.recipe import RecipeSuggestion
|
||||
|
||||
|
||||
# IDs in range -100..-1 are reserved for assembly-generated suggestions
|
||||
_ASSEMBLY_ID_START = -1
|
||||
|
||||
|
|
|
|||
|
|
@ -20,13 +20,21 @@ from typing import TYPE_CHECKING
|
|||
if TYPE_CHECKING:
|
||||
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.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.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
|
||||
|
||||
|
|
@ -398,9 +406,19 @@ def _expand_pantry_set(
|
|||
# Extract individual ingredient tokens from multi-word product names.
|
||||
# "Organic Extra Firm Tofu" → adds "tofu"; "Brown Basmati Rice" → adds "rice".
|
||||
# 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():
|
||||
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)
|
||||
# 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"
|
||||
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:
|
||||
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]
|
||||
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 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]:
|
||||
return frozenset(
|
||||
w for w in text.lower().split()
|
||||
|
|
|
|||
|
|
@ -152,8 +152,8 @@ def _call_via_local_vlm(image_paths: list[Path], prompt: str) -> str:
|
|||
raise RuntimeError("No CUDA device -- local VLM unavailable")
|
||||
|
||||
# Lazy import so the module loads fast when GPU is absent
|
||||
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
|
||||
from PIL import Image, ImageOps
|
||||
from transformers import AutoProcessor, Qwen2VLForConditionalGeneration
|
||||
|
||||
model_name = "Qwen/Qwen2.5-VL-7B-Instruct"
|
||||
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")
|
||||
if cf_orch_url:
|
||||
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 app.services.ocr.docuvision_client import DocuvisionClient
|
||||
from app.services.task_inference import TaskNotRegistered, task_allocate
|
||||
|
||||
try:
|
||||
_progress("allocating", "Starting vision service...")
|
||||
with task_allocate("kiwi", "recipe_scan", service_hint="cf-docuvision", ttl_s=120.0) as alloc:
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Environment:
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.models.schemas.recipe import RecipeRequest, RecipeSuggestion
|
||||
|
||||
|
|
|
|||
|
|
@ -51,8 +51,9 @@ def _build_router():
|
|||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||
if cf_orch_url:
|
||||
try:
|
||||
from app.services.meal_plan.llm_router import _OrchTextRouter # reuse adapter
|
||||
from circuitforge_orch.client import CFOrchClient
|
||||
|
||||
from app.services.meal_plan.llm_router import _OrchTextRouter # reuse adapter
|
||||
client = CFOrchClient(cf_orch_url)
|
||||
ctx = client.allocate(service=_SERVICE_TYPE, ttl_s=_TTL_S, caller=_CALLER)
|
||||
alloc = ctx.__enter__()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ Powered by:
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ from __future__ import annotations
|
|||
|
||||
import re
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Text-signal tables
|
||||
# (tag, [case-insensitive substrings to search in combined title+ingredient text])
|
||||
|
|
|
|||
|
|
@ -11,7 +11,11 @@ from pathlib import Path
|
|||
|
||||
from circuitforge_core.tasks.scheduler import (
|
||||
TaskScheduler,
|
||||
)
|
||||
from circuitforge_core.tasks.scheduler import (
|
||||
get_scheduler as _base_get_scheduler,
|
||||
)
|
||||
from circuitforge_core.tasks.scheduler import (
|
||||
reset_scheduler as _reset_local, # re-export for tests
|
||||
)
|
||||
|
||||
|
|
|
|||
20
app/tiers.py
|
|
@ -8,7 +8,7 @@ Uses circuitforge-core can_use() with Kiwi's feature map.
|
|||
"""
|
||||
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.
|
||||
KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||
|
|
@ -49,20 +49,18 @@ KIWI_FEATURES: dict[str, str] = {
|
|||
"recipe_suggestions": "paid", # BYOK-unlockable
|
||||
"expiry_llm_matching": "paid", # BYOK-unlockable
|
||||
"meal_planning": "free",
|
||||
"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
|
||||
"meal_plan_config": "paid", # configurable meal types (breakfast/lunch/snack)
|
||||
"dietary_profiles": "paid",
|
||||
"style_picker": "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_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
||||
"community_publish": "paid", # Publish plans/outcomes to community feed
|
||||
|
||||
# Paid tier (continued)
|
||||
"recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe
|
||||
|
||||
# Premium tier
|
||||
# Premium tier — heavy LLM features (model call per request)
|
||||
"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
|
||||
"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",
|
||||
"background_monitoring": "premium",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@
|
|||
"""
|
||||
Utility functions for Kiwi.
|
||||
Contains common helpers used throughout the application.
|
||||
"""
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
# app/utils/progress.py
|
||||
import sys
|
||||
import time
|
||||
import asyncio
|
||||
from typing import Optional, Callable, Any
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
|
||||
class ProgressIndicator:
|
||||
"""
|
||||
r"""
|
||||
A simple progress indicator for long-running operations.
|
||||
|
||||
This class provides different styles of progress indicators:
|
||||
|
|
@ -14,10 +15,10 @@ class ProgressIndicator:
|
|||
- spinner: Spinning cursor (|/-\)
|
||||
- percentage: Progress percentage [#### ] 40%
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
message: str = "Processing",
|
||||
style: str = "dots",
|
||||
|
||||
def __init__(self,
|
||||
message: str = "Processing",
|
||||
style: str = "dots",
|
||||
total: Optional[int] = None):
|
||||
"""
|
||||
Initialize the progress indicator.
|
||||
|
|
@ -35,23 +36,23 @@ class ProgressIndicator:
|
|||
self._running = False
|
||||
self._thread = None
|
||||
self._task = None
|
||||
|
||||
|
||||
# Validate style
|
||||
if style not in ["dots", "spinner", "percentage"]:
|
||||
raise ValueError("Style must be 'dots', 'spinner', or 'percentage'")
|
||||
|
||||
|
||||
# Validate total for percentage style
|
||||
if style == "percentage" and total is None:
|
||||
raise ValueError("Total must be specified for percentage style")
|
||||
|
||||
|
||||
def start(self):
|
||||
"""Start the progress indicator in a separate thread."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
|
||||
self._running = True
|
||||
self.start_time = time.time()
|
||||
|
||||
|
||||
# Start the appropriate indicator
|
||||
if self.style == "dots":
|
||||
self._thread = threading.Thread(target=self._dots_indicator)
|
||||
|
|
@ -59,18 +60,18 @@ class ProgressIndicator:
|
|||
self._thread = threading.Thread(target=self._spinner_indicator)
|
||||
elif self.style == "percentage":
|
||||
self._thread = threading.Thread(target=self._percentage_indicator)
|
||||
|
||||
|
||||
self._thread.daemon = True
|
||||
self._thread.start()
|
||||
|
||||
|
||||
async def start_async(self):
|
||||
"""Start the progress indicator as an asyncio task."""
|
||||
if self._running:
|
||||
return
|
||||
|
||||
|
||||
self._running = True
|
||||
self.start_time = time.time()
|
||||
|
||||
|
||||
# Start the appropriate indicator
|
||||
if self.style == "dots":
|
||||
self._task = asyncio.create_task(self._dots_indicator_async())
|
||||
|
|
@ -78,43 +79,43 @@ class ProgressIndicator:
|
|||
self._task = asyncio.create_task(self._spinner_indicator_async())
|
||||
elif self.style == "percentage":
|
||||
self._task = asyncio.create_task(self._percentage_indicator_async())
|
||||
|
||||
|
||||
def update(self, current: int):
|
||||
"""Update the progress (for percentage style)."""
|
||||
self.current = current
|
||||
|
||||
|
||||
def stop(self):
|
||||
"""Stop the progress indicator."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
|
||||
self._running = False
|
||||
|
||||
|
||||
if self._thread:
|
||||
self._thread.join(timeout=1.0)
|
||||
|
||||
|
||||
# Clear the progress line and write a newline
|
||||
sys.stdout.write("\r" + " " * 80 + "\r")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
async def stop_async(self):
|
||||
"""Stop the progress indicator (async version)."""
|
||||
if not self._running:
|
||||
return
|
||||
|
||||
|
||||
self._running = False
|
||||
|
||||
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
# Clear the progress line and write a newline
|
||||
sys.stdout.write("\r" + " " * 80 + "\r")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def _dots_indicator(self):
|
||||
"""Display an animated dots indicator."""
|
||||
i = 0
|
||||
|
|
@ -125,7 +126,7 @@ class ProgressIndicator:
|
|||
sys.stdout.flush()
|
||||
time.sleep(0.5)
|
||||
i += 1
|
||||
|
||||
|
||||
async def _dots_indicator_async(self):
|
||||
"""Display an animated dots indicator (async version)."""
|
||||
i = 0
|
||||
|
|
@ -136,7 +137,7 @@ class ProgressIndicator:
|
|||
sys.stdout.flush()
|
||||
await asyncio.sleep(0.5)
|
||||
i += 1
|
||||
|
||||
|
||||
def _spinner_indicator(self):
|
||||
"""Display a spinning cursor indicator."""
|
||||
chars = "|/-\\"
|
||||
|
|
@ -148,7 +149,7 @@ class ProgressIndicator:
|
|||
sys.stdout.flush()
|
||||
time.sleep(0.1)
|
||||
i += 1
|
||||
|
||||
|
||||
async def _spinner_indicator_async(self):
|
||||
"""Display a spinning cursor indicator (async version)."""
|
||||
chars = "|/-\\"
|
||||
|
|
@ -160,7 +161,7 @@ class ProgressIndicator:
|
|||
sys.stdout.flush()
|
||||
await asyncio.sleep(0.1)
|
||||
i += 1
|
||||
|
||||
|
||||
def _percentage_indicator(self):
|
||||
"""Display a percentage progress bar."""
|
||||
while self._running:
|
||||
|
|
@ -169,17 +170,17 @@ class ProgressIndicator:
|
|||
filled_length = int(bar_length * percentage // 100)
|
||||
bar = '#' * filled_length + ' ' * (bar_length - filled_length)
|
||||
elapsed = time.time() - self.start_time
|
||||
|
||||
|
||||
# Estimate time remaining if we have progress
|
||||
if percentage > 0:
|
||||
remaining = elapsed * (100 - percentage) / percentage
|
||||
sys.stdout.write(f"\r{self.message} [{bar}] {percentage}% ({elapsed:.1f}s elapsed, ~{remaining:.1f}s remaining)")
|
||||
else:
|
||||
sys.stdout.write(f"\r{self.message} [{bar}] {percentage}% ({elapsed:.1f}s elapsed)")
|
||||
|
||||
|
||||
sys.stdout.flush()
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
async def _percentage_indicator_async(self):
|
||||
"""Display a percentage progress bar (async version)."""
|
||||
while self._running:
|
||||
|
|
@ -188,14 +189,14 @@ class ProgressIndicator:
|
|||
filled_length = int(bar_length * percentage // 100)
|
||||
bar = '#' * filled_length + ' ' * (bar_length - filled_length)
|
||||
elapsed = time.time() - self.start_time
|
||||
|
||||
|
||||
# Estimate time remaining if we have progress
|
||||
if percentage > 0:
|
||||
remaining = elapsed * (100 - percentage) / percentage
|
||||
sys.stdout.write(f"\r{self.message} [{bar}] {percentage}% ({elapsed:.1f}s elapsed, ~{remaining:.1f}s remaining)")
|
||||
else:
|
||||
sys.stdout.write(f"\r{self.message} [{bar}] {percentage}% ({elapsed:.1f}s elapsed)")
|
||||
|
||||
|
||||
sys.stdout.flush()
|
||||
await asyncio.sleep(0.2)
|
||||
|
||||
|
|
@ -216,7 +217,7 @@ def with_progress(func: Callable, *args, message: str = "Processing", style: str
|
|||
"""
|
||||
progress = ProgressIndicator(message=message, style=style)
|
||||
progress.start()
|
||||
|
||||
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
return result
|
||||
|
|
@ -240,9 +241,9 @@ async def with_progress_async(func: Callable, *args, message: str = "Processing"
|
|||
"""
|
||||
progress = ProgressIndicator(message=message, style=style)
|
||||
await progress.start_async()
|
||||
|
||||
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
return result
|
||||
finally:
|
||||
await progress.stop_async()
|
||||
await progress.stop_async()
|
||||
|
|
|
|||
10
docs/stylesheets/theme.css
Normal 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;
|
||||
}
|
||||
BIN
frontend/public/favicon-64.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
frontend/public/icon-192.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
frontend/public/icon-512.png
Normal file
|
After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 139 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 139 KiB |
|
|
@ -48,21 +48,25 @@
|
|||
<label class="form-label">Title <span class="form-required">*</span></label>
|
||||
<input
|
||||
v-model="form.title"
|
||||
class="form-input"
|
||||
:class="['form-input', { 'field-invalid': touched.title && !form.title.trim() }]"
|
||||
type="text"
|
||||
placeholder="Short summary of the issue or idea"
|
||||
maxlength="120"
|
||||
@blur="touched.title = true"
|
||||
/>
|
||||
<p v-if="touched.title && !form.title.trim()" class="field-error">Required</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">Description <span class="form-required">*</span></label>
|
||||
<textarea
|
||||
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…"
|
||||
rows="4"
|
||||
@blur="touched.description = true"
|
||||
/>
|
||||
<p v-if="touched.description && !form.description.trim()" class="field-error">Required</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.type === 'bug'" class="form-group">
|
||||
|
|
@ -135,6 +139,8 @@
|
|||
v-if="step === 1"
|
||||
class="btn btn-primary"
|
||||
@click="nextStep"
|
||||
:disabled="!canProceed"
|
||||
:title="!canProceed ? 'Fill in Title and Description to continue' : undefined"
|
||||
>Next →</button>
|
||||
<button
|
||||
v-if="step === 2 && !submitted"
|
||||
|
|
@ -151,7 +157,7 @@
|
|||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
|
||||
const props = defineProps<{ currentTab?: string }>()
|
||||
|
||||
|
|
@ -215,6 +221,9 @@ const form = ref({
|
|||
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 ?? '')
|
||||
|
||||
function close() {
|
||||
|
|
@ -231,13 +240,16 @@ function reset() {
|
|||
submitted.value = false
|
||||
issueUrl.value = ''
|
||||
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
|
||||
touched.title = false
|
||||
touched.description = false
|
||||
clearScreenshot()
|
||||
}
|
||||
|
||||
function nextStep() {
|
||||
touched.title = true
|
||||
touched.description = true
|
||||
stepError.value = ''
|
||||
if (!form.value.title.trim() || !form.value.description.trim()) {
|
||||
stepError.value = 'Please fill in both Title and Description.'
|
||||
if (!canProceed.value) {
|
||||
return
|
||||
}
|
||||
step.value = 2
|
||||
|
|
@ -297,7 +309,7 @@ async function submit() {
|
|||
.feedback-fab {
|
||||
position: fixed;
|
||||
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;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -326,7 +338,7 @@ async function submit() {
|
|||
/* On desktop, bottom nav is gone — drop to standard corner */
|
||||
@media (min-width: 769px) {
|
||||
.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;
|
||||
gap: var(--spacing-sm);
|
||||
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);
|
||||
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 {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
|
|
@ -426,6 +446,21 @@ async function submit() {
|
|||
|
||||
.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 {
|
||||
color: var(--color-error);
|
||||
font-size: var(--font-size-sm);
|
||||
|
|
|
|||
|
|
@ -382,6 +382,7 @@ export const inventoryAPI = {
|
|||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
timeout: 90000, // OFF lookup + LLM expiry prediction can take 30-60s on cold start
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
|
|
|||
11
mkdocs.yml
|
|
@ -9,14 +9,14 @@ theme:
|
|||
name: material
|
||||
palette:
|
||||
- scheme: default
|
||||
primary: light green
|
||||
accent: green
|
||||
primary: green
|
||||
accent: teal
|
||||
toggle:
|
||||
icon: material/brightness-7
|
||||
name: Switch to dark mode
|
||||
- scheme: slate
|
||||
primary: light green
|
||||
accent: green
|
||||
primary: green
|
||||
accent: lime
|
||||
toggle:
|
||||
icon: material/brightness-4
|
||||
name: Switch to light mode
|
||||
|
|
@ -64,5 +64,8 @@ nav:
|
|||
- Tier System: reference/tier-system.md
|
||||
- Architecture: reference/architecture.md
|
||||
|
||||
extra_css:
|
||||
- stylesheets/theme.css
|
||||
|
||||
extra_javascript:
|
||||
- plausible.js
|
||||
|
|
|
|||
|
|
@ -35,3 +35,22 @@ include = ["app*"]
|
|||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
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"]
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ def backfill(db_path: Path, batch_size: int = 5000) -> None:
|
|||
fixed += len(updates)
|
||||
|
||||
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))
|
||||
print(f" {pct:>3}% processed {offset:,} fixed {fixed:,} skipped {skipped:,}", end="\r")
|
||||
|
||||
|
|
|
|||
129
scripts/logo/composite.py
Normal 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.")
|
||||
154
scripts/logo/generate_bird_silhouette.py
Normal 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}")
|
||||
114
scripts/logo/generate_disc.py
Normal 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")
|
||||
BIN
scripts/logo/kiwi_bird_final.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
88
scripts/logo/kiwi_disc.svg
Normal 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 |
|
|
@ -10,6 +10,7 @@ Usage:
|
|||
--flavorgraph-dir /tmp/flavorgraph/input
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Usage:
|
|||
--usda-branded data/usda_branded.parquet
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
|
|
@ -16,7 +17,6 @@ from pathlib import Path
|
|||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
# ── Element derivation rules (threshold-based) ────────────────────────────
|
||||
|
||||
_ELEMENT_RULES: list[tuple[str, callable]] = [
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Usage:
|
|||
--batch-size 10000
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ Usage:
|
|||
--recipepairs-recipes data/pipeline/recipepairs_recipes.parquet
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ Downloads:
|
|||
- lishuyang/recipepairs (GPL-3.0 ⚠) → data/pipeline/recipepairs.parquet [derive only, don't ship]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
|
|
@ -19,7 +20,6 @@ from pathlib import Path
|
|||
from datasets import load_dataset
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
|
||||
# Standard HuggingFace datasets: (hf_path, split, output_filename)
|
||||
HF_DATASETS = [
|
||||
("corbt/all-recipes", "train", "recipes_allrecipes.parquet"),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ Usage:
|
|||
--db /path/to/kiwi.db
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
|||
|
||||
from app.services.recipe.tag_inferrer import infer_tags
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Substitution constraint label mapping
|
||||
# Keys are what we store in substitution_pairs.constraint_label.
|
||||
|
|
|
|||
|
|
@ -15,11 +15,10 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import math
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
|
@ -98,7 +97,7 @@ def ingest(db_path: Path, parquet_path: Path) -> None:
|
|||
|
||||
# Filter to rows with full recipe data
|
||||
if "HasFullRecipe" in df.columns:
|
||||
df = df[df["HasFullRecipe"] == True].copy()
|
||||
df = df[df["HasFullRecipe"]].copy()
|
||||
|
||||
if df.empty:
|
||||
print("No full recipes found in parquet — nothing to ingest.")
|
||||
|
|
|
|||
|
|
@ -109,11 +109,11 @@ def main() -> None:
|
|||
for s in slugs:
|
||||
print(f" {s}")
|
||||
print(f"\nSaved {len(df_new)} total slugs (accumulated) to {args.out}")
|
||||
print(f"\nTo scrape full recipes:")
|
||||
print(f" conda run -n cf python3 scripts/pipeline/purple_carrot/scrape_live.py \\")
|
||||
print("\nTo scrape full recipes:")
|
||||
print(" conda run -n cf python3 scripts/pipeline/purple_carrot/scrape_live.py \\")
|
||||
print(f" --slugs-from {args.out} \\")
|
||||
print(f" --out /Library/Assets/kiwi/pipeline/recipes_purplecarrot_live.parquet \\")
|
||||
print(f" --resume")
|
||||
print(" --out /Library/Assets/kiwi/pipeline/recipes_purplecarrot_live.parquet \\")
|
||||
print(" --resume")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ import logging
|
|||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ Usage:
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
"""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
|
||||
|
||||
|
||||
|
|
@ -92,7 +90,6 @@ class TestDetailTimeEffortField:
|
|||
|
||||
def test_time_effort_field_structure(self):
|
||||
"""Detail endpoint must return the full TimeEffortProfile shape."""
|
||||
import json
|
||||
from app.services.recipe.time_effort import parse_time_effort
|
||||
|
||||
directions = [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# tests/api/test_community_endpoints.py
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
|
|
|||
|
|
@ -4,12 +4,10 @@ from __future__ import annotations
|
|||
from collections.abc import Callable
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from circuitforge_core.api.feedback import make_feedback_router
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from circuitforge_core.api.feedback import make_feedback_router
|
||||
|
||||
|
||||
# ── Test app factory ──────────────────────────────────────────────────────────
|
||||
|
||||
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"""
|
||||
import io
|
||||
import os
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
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.db.session import get_store
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
|
@ -213,7 +212,7 @@ class TestScanTextWithCaptureGating:
|
|||
|
||||
def _off_patch(self, result):
|
||||
"""Patch OpenFoodFactsService.lookup_product at the class level."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock
|
||||
return patch(
|
||||
"app.services.openfoodfacts.OpenFoodFactsService.lookup_product",
|
||||
new=AsyncMock(return_value=result),
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
"""Integration tests for /api/v1/meal-plans/ endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.cloud_session import CloudUser, get_session
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
|||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.cloud_session import CloudUser, get_session
|
||||
|
|
|
|||
|
|
@ -4,16 +4,14 @@ VLM calls are mocked at the service level -- no GPU or API key needed.
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.cloud_session import get_session
|
||||
from app.db.session import get_store
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
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.db.session import get_store
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.cloud_session import CloudUser, get_session
|
||||
|
|
|
|||