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=
|
# E2E_TEST_USER_ID=
|
||||||
|
|
||||||
# In-app feedback → Forgejo issue creation
|
# In-app feedback → Forgejo issue creation
|
||||||
# FORGEJO_API_TOKEN=
|
# FORGEJO_API_TOKEN= # dev/admin token
|
||||||
|
# FORGEJO_BOT_TOKEN= # cf-bugbot token — used for in-app feedback; falls back to FORGEJO_API_TOKEN
|
||||||
# FORGEJO_REPO=Circuit-Forge/kiwi
|
# FORGEJO_REPO=Circuit-Forge/kiwi
|
||||||
# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
# FORGEJO_API_URL=https://git.opensourcesolarpunk.com/api/v1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request, Response
|
from fastapi import APIRouter, HTTPException, Request, Response
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.services.ap.keys import get_actor
|
from app.services.ap.keys import get_actor
|
||||||
|
|
@ -92,7 +91,6 @@ async def _on_follow(activity: dict, headers: dict) -> None:
|
||||||
if not actor_url:
|
if not actor_url:
|
||||||
return
|
return
|
||||||
|
|
||||||
from app.db.store import Store
|
|
||||||
from app.core.config import settings as _settings
|
from app.core.config import settings as _settings
|
||||||
db_path = _settings.DB_PATH
|
db_path = _settings.DB_PATH
|
||||||
|
|
||||||
|
|
@ -284,6 +282,7 @@ async def get_following():
|
||||||
|
|
||||||
def _post_to_ap_note(post, actor, base_url: str) -> dict:
|
def _post_to_ap_note(post, actor, base_url: str) -> dict:
|
||||||
from circuitforge_core.activitypub import make_note
|
from circuitforge_core.activitypub import make_note
|
||||||
|
|
||||||
from app.services.community.ap_compat import _build_content
|
from app.services.community.ap_compat import _build_content
|
||||||
|
|
||||||
diet_tags: list[str] = list(getattr(post, "dietary_tags", []) or [])
|
diet_tags: list[str] = list(getattr(post, "dietary_tags", []) or [])
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ from datetime import datetime, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
from app.core.config import settings
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.services.community.feed import posts_to_rss
|
from app.services.community.feed import posts_to_rss
|
||||||
|
|
||||||
|
|
@ -36,6 +35,7 @@ def init_community_store(community_db_url: str | None) -> None:
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
from circuitforge_core.community import CommunityDB
|
from circuitforge_core.community import CommunityDB
|
||||||
|
|
||||||
from app.services.community.community_store import KiwiCommunityStore
|
from app.services.community.community_store import KiwiCommunityStore
|
||||||
db = CommunityDB(dsn=community_db_url)
|
db = CommunityDB(dsn=community_db_url)
|
||||||
db.run_migrations()
|
db.run_migrations()
|
||||||
|
|
@ -307,9 +307,10 @@ async def publish_post(body: dict, session: CloudUser = Depends(get_session)):
|
||||||
# AP delivery + Mastodon post (Paid tier, AP_ENABLED, opted-in)
|
# AP delivery + Mastodon post (Paid tier, AP_ENABLED, opted-in)
|
||||||
from app.core.config import settings as _settings
|
from app.core.config import settings as _settings
|
||||||
if _settings.AP_ENABLED and session.tier in ("paid", "premium", "ultra"):
|
if _settings.AP_ENABLED and session.tier in ("paid", "premium", "ultra"):
|
||||||
from circuitforge_core.activitypub import make_create, make_note, PUBLIC
|
from circuitforge_core.activitypub import make_create
|
||||||
from app.services.ap.keys import get_actor
|
|
||||||
from app.services.ap.delivery import deliver_to_followers
|
from app.services.ap.delivery import deliver_to_followers
|
||||||
|
from app.services.ap.keys import get_actor
|
||||||
_ap_actor = get_actor()
|
_ap_actor = get_actor()
|
||||||
if _ap_actor is not None:
|
if _ap_actor is not None:
|
||||||
base = f"https://{_settings.AP_HOST}"
|
base = f"https://{_settings.AP_HOST}"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# app/api/endpoints/corrections.py — user corrections to LLM output for SFT training
|
# app/api/endpoints/corrections.py — user corrections to LLM output for SFT training
|
||||||
from circuitforge_core.api import make_corrections_router
|
from circuitforge_core.api import make_corrections_router
|
||||||
|
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
|
||||||
router = make_corrections_router(get_db=get_db, product="kiwi")
|
router = make_corrections_router(get_db=get_db, product="kiwi")
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"""Feedback router — provided by circuitforge-core."""
|
"""Feedback router — provided by circuitforge-core."""
|
||||||
from circuitforge_core.api import make_feedback_router
|
from circuitforge_core.api import make_feedback_router
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
router = make_feedback_router(
|
router = make_feedback_router(
|
||||||
|
|
|
||||||
|
|
@ -35,9 +35,12 @@ class AttachResponse(BaseModel):
|
||||||
comment_url: str
|
comment_url: str
|
||||||
|
|
||||||
|
|
||||||
|
def _forgejo_token() -> str:
|
||||||
|
return os.environ.get("FORGEJO_BOT_TOKEN") or os.environ.get("FORGEJO_API_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
def _forgejo_headers() -> dict[str, str]:
|
def _forgejo_headers() -> dict[str, str]:
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
return {"Authorization": f"token {_forgejo_token()}"}
|
||||||
return {"Authorization": f"token {token}"}
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_image(image_b64: str) -> tuple[bytes, str]:
|
def _decode_image(image_b64: str) -> tuple[bytes, str]:
|
||||||
|
|
@ -58,7 +61,7 @@ def attach_screenshot(payload: AttachRequest) -> AttachResponse:
|
||||||
The image is uploaded as an issue asset, then referenced in a comment
|
The image is uploaded as an issue asset, then referenced in a comment
|
||||||
so it is visible inline when the issue is viewed.
|
so it is visible inline when the issue is viewed.
|
||||||
"""
|
"""
|
||||||
token = os.environ.get("FORGEJO_API_TOKEN", "")
|
token = _forgejo_token()
|
||||||
if not token:
|
if not token:
|
||||||
raise HTTPException(status_code=503, detail="Feedback not configured.")
|
raise HTTPException(status_code=503, detail="Feedback not configured.")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,13 @@ from __future__ import annotations
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, CLOUD_DATA_ROOT, get_session
|
from app.cloud_session import CLOUD_DATA_ROOT, CloudUser, get_session
|
||||||
from app.services.heimdall_orch import HEIMDALL_URL, HEIMDALL_ADMIN_TOKEN
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.household import (
|
from app.models.schemas.household import (
|
||||||
HouseholdAcceptRequest,
|
HouseholdAcceptRequest,
|
||||||
|
|
@ -24,6 +22,7 @@ from app.models.schemas.household import (
|
||||||
HouseholdStatusResponse,
|
HouseholdStatusResponse,
|
||||||
MessageResponse,
|
MessageResponse,
|
||||||
)
|
)
|
||||||
|
from app.services.heimdall_orch import HEIMDALL_ADMIN_TOKEN, HEIMDALL_URL
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from app.cloud_session import get_session, CloudUser
|
from app.cloud_session import CloudUser, get_session
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import aiofiles
|
import aiofiles
|
||||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||||
|
|
@ -114,7 +114,6 @@ async def get_product(product_id: int, store: Store = Depends(get_store)):
|
||||||
|
|
||||||
@router.get("/products/barcode/{barcode}", response_model=ProductResponse)
|
@router.get("/products/barcode/{barcode}", response_model=ProductResponse)
|
||||||
async def get_product_by_barcode(barcode: str, store: Store = Depends(get_store)):
|
async def get_product_by_barcode(barcode: str, store: Store = Depends(get_store)):
|
||||||
from app.db import store as store_module # avoid circular
|
|
||||||
product = await asyncio.to_thread(
|
product = await asyncio.to_thread(
|
||||||
store._fetch_one, "SELECT * FROM products WHERE barcode = ?", (barcode,)
|
store._fetch_one, "SELECT * FROM products WHERE barcode = ?", (barcode,)
|
||||||
)
|
)
|
||||||
|
|
@ -383,8 +382,8 @@ async def scan_barcode_text(
|
||||||
):
|
):
|
||||||
"""Scan a barcode from a text string (e.g. from a hardware scanner or manual entry)."""
|
"""Scan a barcode from a text string (e.g. from a hardware scanner or manual entry)."""
|
||||||
log.info("scan auth=%s tier=%s barcode=%r", _auth_label(session.user_id), session.tier, body.barcode)
|
log.info("scan auth=%s tier=%s barcode=%r", _auth_label(session.user_id), session.tier, body.barcode)
|
||||||
from app.services.openfoodfacts import OpenFoodFactsService
|
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
from app.services.openfoodfacts import OpenFoodFactsService
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
predictor = ExpirationPredictor()
|
predictor = ExpirationPredictor()
|
||||||
|
|
@ -475,8 +474,8 @@ async def scan_barcode_image(
|
||||||
async with aiofiles.open(temp_file, "wb") as f:
|
async with aiofiles.open(temp_file, "wb") as f:
|
||||||
await f.write(await file.read())
|
await f.write(await file.read())
|
||||||
from app.services.barcode_scanner import BarcodeScanner
|
from app.services.barcode_scanner import BarcodeScanner
|
||||||
from app.services.openfoodfacts import OpenFoodFactsService
|
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
from app.services.openfoodfacts import OpenFoodFactsService
|
||||||
|
|
||||||
image_bytes = temp_file.read_bytes()
|
image_bytes = temp_file.read_bytes()
|
||||||
barcodes = await asyncio.to_thread(BarcodeScanner().scan_from_bytes, image_bytes)
|
barcodes = await asyncio.to_thread(BarcodeScanner().scan_from_bytes, image_bytes)
|
||||||
|
|
@ -568,9 +567,10 @@ async def capture_nutrition_label(
|
||||||
for user review. Fields extracted with confidence < 0.7 should be
|
for user review. Fields extracted with confidence < 0.7 should be
|
||||||
highlighted in amber in the UI.
|
highlighted in amber in the UI.
|
||||||
"""
|
"""
|
||||||
from app.tiers import can_use
|
|
||||||
from app.models.schemas.label_capture import LabelCaptureResponse
|
from app.models.schemas.label_capture import LabelCaptureResponse
|
||||||
from app.services.label_capture import extract_label, needs_review as _needs_review
|
from app.services.label_capture import extract_label
|
||||||
|
from app.services.label_capture import needs_review as _needs_review
|
||||||
|
from app.tiers import can_use
|
||||||
|
|
||||||
if not can_use("visual_label_capture", session.tier, session.has_byok):
|
if not can_use("visual_label_capture", session.tier, session.has_byok):
|
||||||
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
|
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
|
||||||
|
|
@ -611,9 +611,9 @@ async def confirm_nutrition_label(
|
||||||
resolve instantly without another capture. Optionally adds the item to
|
resolve instantly without another capture. Optionally adds the item to
|
||||||
the user's inventory.
|
the user's inventory.
|
||||||
"""
|
"""
|
||||||
from app.tiers import can_use
|
|
||||||
from app.models.schemas.label_capture import LabelConfirmResponse
|
from app.models.schemas.label_capture import LabelConfirmResponse
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
from app.tiers import can_use
|
||||||
|
|
||||||
if not can_use("visual_label_capture", session.tier, session.has_byok):
|
if not can_use("visual_label_capture", session.tier, session.has_byok):
|
||||||
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
|
raise HTTPException(status_code=403, detail="Visual label capture requires a Paid tier or higher.")
|
||||||
|
|
@ -700,7 +700,8 @@ async def create_tag(body: TagCreate, store: Store = Depends(get_store)):
|
||||||
(body.name, body.slug, body.description, body.color, body.category),
|
(body.name, body.slug, body.description, body.color, body.category),
|
||||||
)
|
)
|
||||||
store.conn.commit()
|
store.conn.commit()
|
||||||
import sqlite3; store.conn.row_factory = sqlite3.Row
|
import sqlite3
|
||||||
|
store.conn.row_factory = sqlite3.Row
|
||||||
return TagResponse.model_validate(store._row_to_dict(cur.fetchone()))
|
return TagResponse.model_validate(store._row_to_dict(cur.fetchone()))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
@ -42,6 +41,7 @@ async def connect_mastodon(body: dict, session: CloudUser = Depends(get_session)
|
||||||
Returns: {"authorize_url": "..."}
|
Returns: {"authorize_url": "..."}
|
||||||
"""
|
"""
|
||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from app.services.ap.mastodon import build_authorize_url, register_app
|
from app.services.ap.mastodon import build_authorize_url, register_app
|
||||||
|
|
||||||
instance_url = (body.get("instance_url") or "").strip().rstrip("/")
|
instance_url = (body.get("instance_url") or "").strip().rstrip("/")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ from app.cloud_session import CloudUser, get_session
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.meal_plan import (
|
from app.models.schemas.meal_plan import (
|
||||||
|
VALID_MEAL_TYPES,
|
||||||
CreatePlanRequest,
|
CreatePlanRequest,
|
||||||
GapItem,
|
GapItem,
|
||||||
PlanSummary,
|
PlanSummary,
|
||||||
|
|
@ -22,7 +23,6 @@ from app.models.schemas.meal_plan import (
|
||||||
UpdatePlanRequest,
|
UpdatePlanRequest,
|
||||||
UpdatePrepTaskRequest,
|
UpdatePrepTaskRequest,
|
||||||
UpsertSlotRequest,
|
UpsertSlotRequest,
|
||||||
VALID_MEAL_TYPES,
|
|
||||||
)
|
)
|
||||||
from app.services.meal_plan.affiliates import get_retailer_links
|
from app.services.meal_plan.affiliates import get_retailer_links
|
||||||
from app.services.meal_plan.prep_scheduler import build_prep_tasks
|
from app.services.meal_plan.prep_scheduler import build_prep_tasks
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@ from app.core.config import settings
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.receipt import (
|
from app.models.schemas.receipt import (
|
||||||
|
ApprovedInventoryItem,
|
||||||
ApproveOCRRequest,
|
ApproveOCRRequest,
|
||||||
ApproveOCRResponse,
|
ApproveOCRResponse,
|
||||||
ApprovedInventoryItem,
|
|
||||||
)
|
)
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ from app.cloud_session import CloudUser, get_session
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.receipt import ReceiptResponse
|
|
||||||
from app.models.schemas.quality import QualityAssessment
|
from app.models.schemas.quality import QualityAssessment
|
||||||
|
from app.models.schemas.receipt import ReceiptResponse
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
|
||||||
|
|
@ -222,7 +222,7 @@ async def _scan_recipe_sse(saved_paths: list[Path], pantry_names: list[str]):
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
|
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
|
||||||
except RuntimeError as exc:
|
except RuntimeError as exc:
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
|
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": str(exc)})
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logger.exception("Unexpected error in recipe scan thread")
|
logger.exception("Unexpected error in recipe scan thread")
|
||||||
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": "Scan failed unexpectedly."})
|
loop.call_soon_threadsafe(queue.put_nowait, {"status": "error", "message": "Scan failed unexpectedly."})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,6 @@ async def submit_recipe_tag(
|
||||||
_validate_location(body.domain, body.category, body.subcategory)
|
_validate_location(body.domain, body.category, body.subcategory)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import psycopg2.errors # type: ignore[import]
|
|
||||||
row = store.submit_recipe_tag(
|
row = store.submit_recipe_tag(
|
||||||
recipe_id=body.recipe_id,
|
recipe_id=body.recipe_id,
|
||||||
domain=body.domain,
|
domain=body.domain,
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,24 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json as _json_mod
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
import json as _json_mod
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, _auth_label, get_session
|
from app.cloud_session import CloudUser, _auth_label, get_session
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
from app.api.endpoints.imitate import _build_recipe_prompt
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
from app.models.schemas.recipe import (
|
from app.models.schemas.recipe import (
|
||||||
|
AskRecipeHit,
|
||||||
AskRequest,
|
AskRequest,
|
||||||
AskResponse,
|
AskResponse,
|
||||||
AskRecipeHit,
|
|
||||||
AssemblyTemplateOut,
|
AssemblyTemplateOut,
|
||||||
BuildRequest,
|
BuildRequest,
|
||||||
LeftoversResponse,
|
LeftoversResponse,
|
||||||
|
|
@ -31,7 +32,7 @@ from app.models.schemas.recipe import (
|
||||||
StreamTokenResponse,
|
StreamTokenResponse,
|
||||||
)
|
)
|
||||||
from app.services.coordinator_proxy import CoordinatorError, coordinator_authorize
|
from app.services.coordinator_proxy import CoordinatorError, coordinator_authorize
|
||||||
from app.api.endpoints.imitate import _build_recipe_prompt
|
from app.services.heimdall_orch import check_orch_budget
|
||||||
from app.services.recipe.assembly_recipes import (
|
from app.services.recipe.assembly_recipes import (
|
||||||
build_from_selection,
|
build_from_selection,
|
||||||
get_role_candidates,
|
get_role_candidates,
|
||||||
|
|
@ -47,9 +48,8 @@ from app.services.recipe.browser_domains import (
|
||||||
get_subcategory_names,
|
get_subcategory_names,
|
||||||
)
|
)
|
||||||
from app.services.recipe.recipe_engine import RecipeEngine
|
from app.services.recipe.recipe_engine import RecipeEngine
|
||||||
from app.services.recipe.time_effort import parse_time_effort
|
|
||||||
from app.services.recipe.sensory import build_sensory_exclude
|
from app.services.recipe.sensory import build_sensory_exclude
|
||||||
from app.services.heimdall_orch import check_orch_budget
|
from app.services.recipe.time_effort import parse_time_effort
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
@ -149,7 +149,9 @@ async def _enqueue_recipe_job(session: CloudUser, req: RecipeRequest):
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.cloud_session import CLOUD_MODE
|
from app.cloud_session import CLOUD_MODE
|
||||||
from app.tasks.runner import insert_task
|
from app.tasks.runner import insert_task
|
||||||
|
|
||||||
|
|
@ -495,7 +497,7 @@ async def browse_recipes(
|
||||||
"community_tagged": True,
|
"community_tagged": True,
|
||||||
}
|
}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("community tag fallback failed: %s", exc)
|
log.warning("community tag fallback failed: %s", exc)
|
||||||
|
|
||||||
store.log_browser_telemetry(
|
store.log_browser_telemetry(
|
||||||
domain=domain,
|
domain=domain,
|
||||||
|
|
@ -576,7 +578,8 @@ async def build_recipe(
|
||||||
return None
|
return None
|
||||||
# Persist to recipes table so the result can be saved/bookmarked.
|
# Persist to recipes table so the result can be saved/bookmarked.
|
||||||
# external_id encodes template + selections for stable dedup.
|
# external_id encodes template + selections for stable dedup.
|
||||||
import hashlib as _hl, json as _js
|
import hashlib as _hl
|
||||||
|
import json as _js
|
||||||
sel_hash = _hl.md5(
|
sel_hash = _hl.md5(
|
||||||
_js.dumps(req.role_overrides, sort_keys=True).encode()
|
_js.dumps(req.role_overrides, sort_keys=True).encode()
|
||||||
).hexdigest()[:8]
|
).hexdigest()[:8]
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@ def _enrich(item: dict, builder: GroceryLinkBuilder) -> ShoppingItemResponse:
|
||||||
links = builder.build_links(item["name"])
|
links = builder.build_links(item["name"])
|
||||||
return ShoppingItemResponse(
|
return ShoppingItemResponse(
|
||||||
**{**item, "checked": bool(item.get("checked", 0))},
|
**{**item, "checked": bool(item.get("checked", 0))},
|
||||||
grocery_links=[{"ingredient": l.ingredient, "retailer": l.retailer, "url": l.url} for l in links],
|
grocery_links=[{"ingredient": lnk.ingredient, "retailer": lnk.retailer, "url": lnk.url} for lnk in links],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,24 @@
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from app.api.endpoints import health, receipts, export, inventory, ocr, recipes, settings, staples, feedback, feedback_attach, household, saved_recipes, imitate, meal_plans, orch_usage, session, shopping
|
|
||||||
|
from app.api.endpoints import (
|
||||||
|
export,
|
||||||
|
feedback,
|
||||||
|
feedback_attach,
|
||||||
|
health,
|
||||||
|
household,
|
||||||
|
imitate,
|
||||||
|
inventory,
|
||||||
|
meal_plans,
|
||||||
|
ocr,
|
||||||
|
orch_usage,
|
||||||
|
receipts,
|
||||||
|
recipes,
|
||||||
|
saved_recipes,
|
||||||
|
session,
|
||||||
|
settings,
|
||||||
|
shopping,
|
||||||
|
staples,
|
||||||
|
)
|
||||||
from app.api.endpoints.community import router as community_router
|
from app.api.endpoints.community import router as community_router
|
||||||
from app.api.endpoints.corrections import router as corrections_router
|
from app.api.endpoints.corrections import router as corrections_router
|
||||||
from app.api.endpoints.mastodon_oauth import router as mastodon_router
|
from app.api.endpoints.mastodon_oauth import router as mastodon_router
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ import os
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from circuitforge_core.cloud_session import CloudSessionFactory as _CoreFactory, detect_byok
|
from circuitforge_core.cloud_session import CloudSessionFactory as _CoreFactory
|
||||||
|
from circuitforge_core.cloud_session import detect_byok
|
||||||
from fastapi import Depends, HTTPException, Request, Response
|
from fastapi import Depends, HTTPException, Request, Response
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -4,28 +4,28 @@ This file is kept for historical reference only. Nothing imports it.
|
||||||
"""
|
"""
|
||||||
# fmt: off # noqa — dead file, not linted
|
# fmt: off # noqa — dead file, not linted
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
Column,
|
|
||||||
String,
|
|
||||||
Text,
|
|
||||||
Boolean,
|
Boolean,
|
||||||
Numeric,
|
|
||||||
DateTime,
|
|
||||||
Date,
|
|
||||||
ForeignKey,
|
|
||||||
CheckConstraint,
|
CheckConstraint,
|
||||||
|
Column,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
Index,
|
Index,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
Table,
|
Table,
|
||||||
|
Text,
|
||||||
)
|
)
|
||||||
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from datetime import datetime
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
|
|
||||||
|
|
||||||
# Association table for many-to-many relationship between products and tags
|
# Association table for many-to-many relationship between products and tags
|
||||||
product_tags = Table(
|
product_tags = Table(
|
||||||
"product_tags",
|
"product_tags",
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from typing import Any
|
||||||
|
|
||||||
from circuitforge_core.db.base import get_connection
|
from circuitforge_core.db.base import get_connection
|
||||||
from circuitforge_core.db.migrations import run_migrations
|
from circuitforge_core.db.migrations import run_migrations
|
||||||
|
|
||||||
from app.services.recipe.sensory import SensoryExclude, passes_sensory_filter
|
from app.services.recipe.sensory import SensoryExclude, passes_sensory_filter
|
||||||
|
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
@ -304,6 +305,7 @@ class Store:
|
||||||
Returns (updated_count, skipped_count).
|
Returns (updated_count, skipped_count).
|
||||||
"""
|
"""
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
|
||||||
predictor = ExpirationPredictor()
|
predictor = ExpirationPredictor()
|
||||||
|
|
@ -573,13 +575,26 @@ class Store:
|
||||||
if pattern in lower:
|
if pattern in lower:
|
||||||
terms.append(canonical)
|
terms.append(canonical)
|
||||||
|
|
||||||
# For multi-word product names, also add individual significant tokens
|
# For multi-word product names, also add individual significant tokens.
|
||||||
|
# Threshold is 2 (skip only 1-char tokens) so short-but-meaningful
|
||||||
|
# ingredients like "egg", "oil", "soy" are included in FTS queries.
|
||||||
|
# Also add singular/plural variants for each token so "Cage Free Eggs"
|
||||||
|
# generates "egg" as a search term alongside "eggs".
|
||||||
if " " in lower:
|
if " " in lower:
|
||||||
for token in lower.split():
|
for token in lower.split():
|
||||||
if len(token) <= 3 or token in Store._FTS_TOKEN_STOPWORDS:
|
if len(token) <= 2 or token in Store._FTS_TOKEN_STOPWORDS:
|
||||||
continue
|
continue
|
||||||
if token not in terms:
|
if token not in terms:
|
||||||
terms.append(token)
|
terms.append(token)
|
||||||
|
# Add singular/plural variant so "eggs" also searches "egg" etc.
|
||||||
|
if token.endswith("es") and len(token) > 4:
|
||||||
|
variant = token[:-2] # "tomatoes" → "tomato"
|
||||||
|
elif token.endswith("s") and len(token) > 3:
|
||||||
|
variant = token[:-1] # "eggs" → "egg", "florets" → "floret"
|
||||||
|
else:
|
||||||
|
variant = token + "s" # "egg" → "eggs"
|
||||||
|
if variant not in terms:
|
||||||
|
terms.append(variant)
|
||||||
# Synonym-expand individual tokens too
|
# Synonym-expand individual tokens too
|
||||||
if token in Store._FTS_SYNONYMS:
|
if token in Store._FTS_SYNONYMS:
|
||||||
canonical = Store._FTS_SYNONYMS[token]
|
canonical = Store._FTS_SYNONYMS[token]
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,9 @@ async def lifespan(app: FastAPI):
|
||||||
try:
|
try:
|
||||||
from app.db.store import _COUNT_CACHE
|
from app.db.store import _COUNT_CACHE
|
||||||
from app.services.recipe.browse_counts_cache import (
|
from app.services.recipe.browse_counts_cache import (
|
||||||
is_stale, load_into_memory, refresh,
|
is_stale,
|
||||||
|
load_into_memory,
|
||||||
|
refresh,
|
||||||
)
|
)
|
||||||
if is_stale(settings.BROWSE_COUNTS_PATH):
|
if is_stale(settings.BROWSE_COUNTS_PATH):
|
||||||
logger.info("browse_counts: cache stale — refreshing in background...")
|
logger.info("browse_counts: cache stale — refreshing in background...")
|
||||||
|
|
@ -116,6 +118,7 @@ app.include_router(api_router, prefix=settings.API_PREFIX)
|
||||||
|
|
||||||
# AP endpoints: WebFinger at root (not under /api/v1), AP objects under /ap
|
# AP endpoints: WebFinger at root (not under /api/v1), AP objects under /ap
|
||||||
from app.api.endpoints.activitypub import ap_router, webfinger_router
|
from app.api.endpoints.activitypub import ap_router, webfinger_router
|
||||||
|
|
||||||
app.include_router(webfinger_router)
|
app.include_router(webfinger_router)
|
||||||
app.include_router(ap_router)
|
app.include_router(ap_router)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from app.models.schemas.receipt import ReceiptResponse
|
|
||||||
from app.models.schemas.quality import QualityAssessment
|
from app.models.schemas.quality import QualityAssessment
|
||||||
|
from app.models.schemas.receipt import ReceiptResponse
|
||||||
|
|
||||||
__all__ = ["ReceiptResponse", "QualityAssessment"]
|
__all__ = ["ReceiptResponse", "QualityAssessment"]
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,11 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# ── Tags ──────────────────────────────────────────────────────────────────────
|
# ── Tags ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
class TagCreate(BaseModel):
|
class TagCreate(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ from datetime import date as _date
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"}
|
VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@
|
||||||
Pydantic schemas for OCR data models.
|
Pydantic schemas for OCR data models.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import datetime, date, time
|
from datetime import date, datetime, time
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Any, Dict, List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
from pydantic import BaseModel, Field, validator
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class MerchantInfo(BaseModel):
|
class MerchantInfo(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
# ── Ingredient in a scanned recipe ────────────────────────────────────────────
|
# ── Ingredient in a scanned recipe ────────────────────────────────────────────
|
||||||
|
|
||||||
class ScannedIngredientSchema(BaseModel):
|
class ScannedIngredientSchema(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,13 @@ from images (UPC, EAN, QR codes, etc.).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import io
|
import io
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from pyzbar import pyzbar
|
from pyzbar import pyzbar
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Dict, Any, Optional
|
|
||||||
import logging
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from PIL import Image as _PILImage
|
from PIL import Image as _PILImage
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,10 @@ Fallback path: LLMRouter — only fires for unknown products when tier allows it
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import date, timedelta
|
from datetime import date, timedelta
|
||||||
from typing import Optional, List
|
from typing import List, Optional
|
||||||
|
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
|
|
||||||
from app.tiers import can_use
|
from app.tiers import can_use
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,12 @@ This module provides functionality to convert receipt and quality assessment
|
||||||
data into spreadsheet formats for easy viewing and analysis.
|
data into spreadsheet formats for easy viewing and analysis.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pandas as pd
|
from typing import Dict, List, Optional
|
||||||
from datetime import datetime
|
|
||||||
from typing import List, Dict, Optional
|
import pandas as pd
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from app.models.schemas.receipt import ReceiptResponse
|
|
||||||
from app.models.schemas.quality import QualityAssessment
|
from app.models.schemas.quality import QualityAssessment
|
||||||
|
from app.models.schemas.receipt import ReceiptResponse
|
||||||
|
|
||||||
|
|
||||||
class SpreadsheetExporter:
|
class SpreadsheetExporter:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,10 @@ Image preprocessing services for Kiwi.
|
||||||
Contains functions for image enhancement, format conversion, and perspective correction.
|
Contains functions for image enhancement, format conversion, and perspective correction.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from app.services.image_preprocessing.format_conversion import convert_to_standard_format, extract_metadata
|
from app.services.image_preprocessing.enhancement import correct_perspective, enhance_image
|
||||||
from app.services.image_preprocessing.enhancement import enhance_image, correct_perspective
|
from app.services.image_preprocessing.format_conversion import (
|
||||||
|
convert_to_standard_format,
|
||||||
|
extract_metadata,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = ["convert_to_standard_format", "extract_metadata", "enhance_image", "correct_perspective"]
|
__all__ = ["convert_to_standard_format", "extract_metadata", "enhance_image", "correct_perspective"]
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# app/services/image_preprocessing/
|
# app/services/image_preprocessing/
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Tuple, Optional
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# app/services/image_preprocessing/format_conversion.py
|
# app/services/image_preprocessing/format_conversion.py
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Tuple, Optional
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -28,10 +28,10 @@ def convert_to_standard_format(
|
||||||
# Check if CUDA is available and set up GPU processing
|
# Check if CUDA is available and set up GPU processing
|
||||||
if cv2.cuda.getCudaEnabledDeviceCount() > 0:
|
if cv2.cuda.getCudaEnabledDeviceCount() > 0:
|
||||||
logger.info("CUDA available, using GPU acceleration")
|
logger.info("CUDA available, using GPU acceleration")
|
||||||
use_cuda = True
|
use_cuda = True # noqa: F841
|
||||||
else:
|
else:
|
||||||
logger.info("CUDA not available, using CPU processing")
|
logger.info("CUDA not available, using CPU processing")
|
||||||
use_cuda = False
|
use_cuda = False # noqa: F841
|
||||||
|
|
||||||
# Read image
|
# Read image
|
||||||
img = cv2.imread(str(image_path))
|
img = cv2.imread(str(image_path))
|
||||||
|
|
|
||||||
|
|
@ -8,31 +8,31 @@ This service orchestrates:
|
||||||
- Tag management
|
- Tag management
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
import logging
|
||||||
from sqlalchemy import select, func, and_, or_
|
import uuid
|
||||||
from sqlalchemy.orm import selectinload
|
|
||||||
from typing import List, Optional, Dict, Any
|
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
import uuid
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from app.db.models import Product, InventoryItem, Tag, product_tags
|
from sqlalchemy import and_, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.db.models import InventoryItem, Product, Tag
|
||||||
from app.models.schemas.inventory import (
|
from app.models.schemas.inventory import (
|
||||||
ProductCreate,
|
|
||||||
ProductUpdate,
|
|
||||||
ProductResponse,
|
|
||||||
InventoryItemCreate,
|
InventoryItemCreate,
|
||||||
InventoryItemUpdate,
|
|
||||||
InventoryItemResponse,
|
InventoryItemResponse,
|
||||||
TagCreate,
|
InventoryItemUpdate,
|
||||||
TagResponse,
|
|
||||||
InventoryStats,
|
InventoryStats,
|
||||||
|
ProductCreate,
|
||||||
|
ProductResponse,
|
||||||
|
ProductUpdate,
|
||||||
|
TagCreate,
|
||||||
)
|
)
|
||||||
from app.services.barcode_scanner import BarcodeScanner
|
from app.services.barcode_scanner import BarcodeScanner
|
||||||
from app.services.openfoodfacts import OpenFoodFactsService
|
|
||||||
from app.services.expiration_predictor import ExpirationPredictor
|
from app.services.expiration_predictor import ExpirationPredictor
|
||||||
|
from app.services.openfoodfacts import OpenFoodFactsService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ from urllib.parse import quote_plus
|
||||||
|
|
||||||
from circuitforge_core.affiliates import AffiliateProgram, register_program, wrap_url
|
from circuitforge_core.affiliates import AffiliateProgram, register_program, wrap_url
|
||||||
|
|
||||||
|
|
||||||
# ── URL builders ──────────────────────────────────────────────────────────────
|
# ── URL builders ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _walmart_search(url: str, affiliate_id: str) -> str:
|
def _walmart_search(url: str, affiliate_id: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,13 @@ import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from pathlib import Path
|
|
||||||
from typing import Dict, Any, Optional, List
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict
|
||||||
|
|
||||||
from PIL import Image
|
|
||||||
import torch
|
import torch
|
||||||
from transformers import (
|
from PIL import Image
|
||||||
Qwen2VLForConditionalGeneration,
|
from transformers import AutoProcessor, BitsAndBytesConfig, Qwen2VLForConditionalGeneration
|
||||||
AutoProcessor,
|
|
||||||
BitsAndBytesConfig
|
|
||||||
)
|
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
@ -35,8 +31,8 @@ def _try_docuvision(image_path: str | Path) -> str | None:
|
||||||
|
|
||||||
# Tier 1: task-based routing — coordinator owns model selection.
|
# Tier 1: task-based routing — coordinator owns model selection.
|
||||||
try:
|
try:
|
||||||
from app.services.task_inference import task_allocate, TaskNotRegistered
|
|
||||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||||
|
from app.services.task_inference import TaskNotRegistered, task_allocate
|
||||||
try:
|
try:
|
||||||
with task_allocate(
|
with task_allocate(
|
||||||
"kiwi", "ocr",
|
"kiwi", "ocr",
|
||||||
|
|
@ -57,6 +53,7 @@ def _try_docuvision(image_path: str | Path) -> str | None:
|
||||||
# Tier 2: direct allocation — hardcoded service type.
|
# Tier 2: direct allocation — hardcoded service type.
|
||||||
try:
|
try:
|
||||||
from circuitforge_orch.client import CFOrchClient
|
from circuitforge_orch.client import CFOrchClient
|
||||||
|
|
||||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||||
|
|
||||||
client = CFOrchClient(cf_orch_url)
|
client = CFOrchClient(cf_orch_url)
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ This module provides functionality to look up product information
|
||||||
from the OpenFoodFacts database using barcodes (UPC/EAN).
|
from the OpenFoodFacts database using barcodes (UPC/EAN).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import httpx
|
|
||||||
from typing import Optional, Dict, Any
|
|
||||||
from app.core.config import settings
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# app/services/quality/assessment.py
|
# app/services/quality/assessment.py
|
||||||
import cv2
|
|
||||||
import numpy as np
|
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, Optional, Tuple
|
from typing import Any, Dict, Tuple
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,22 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python
|
||||||
# app/services/receipt_service.py
|
# app/services/receipt_service.py
|
||||||
import os
|
|
||||||
import uuid
|
|
||||||
import shutil
|
|
||||||
import aiofiles
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional, List, Dict, Any
|
|
||||||
from fastapi import UploadFile, BackgroundTasks, HTTPException
|
|
||||||
import asyncio
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import uuid
|
||||||
from app.utils.progress import ProgressIndicator
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
import aiofiles
|
||||||
|
from fastapi import BackgroundTasks, HTTPException, UploadFile
|
||||||
|
|
||||||
from app.services.image_preprocessing.format_conversion import convert_to_standard_format, extract_metadata
|
|
||||||
from app.services.image_preprocessing.enhancement import enhance_image, correct_perspective
|
|
||||||
from app.services.quality.assessment import QualityAssessor
|
|
||||||
from app.models.schemas.receipt import ReceiptCreate, ReceiptResponse
|
|
||||||
from app.models.schemas.quality import QualityAssessment
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
from app.models.schemas.quality import QualityAssessment
|
||||||
|
from app.models.schemas.receipt import ReceiptResponse
|
||||||
|
from app.services.image_preprocessing.enhancement import correct_perspective, enhance_image
|
||||||
|
from app.services.image_preprocessing.format_conversion import (
|
||||||
|
convert_to_standard_format,
|
||||||
|
extract_metadata,
|
||||||
|
)
|
||||||
|
from app.services.quality.assessment import QualityAssessor
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,6 @@ from dataclasses import dataclass
|
||||||
|
|
||||||
from app.models.schemas.recipe import RecipeSuggestion
|
from app.models.schemas.recipe import RecipeSuggestion
|
||||||
|
|
||||||
|
|
||||||
# IDs in range -100..-1 are reserved for assembly-generated suggestions
|
# IDs in range -100..-1 are reserved for assembly-generated suggestions
|
||||||
_ASSEMBLY_ID_START = -1
|
_ASSEMBLY_ID_START = -1
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,13 +20,21 @@ from typing import TYPE_CHECKING
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
||||||
from app.models.schemas.recipe import GroceryLink, NutritionPanel, RecipeRequest, RecipeResult, RecipeSuggestion, StepAnalysis, TimeEffortProfile, SwapCandidate
|
from app.models.schemas.recipe import (
|
||||||
|
NutritionPanel,
|
||||||
|
RecipeRequest,
|
||||||
|
RecipeResult,
|
||||||
|
RecipeSuggestion,
|
||||||
|
StepAnalysis,
|
||||||
|
SwapCandidate,
|
||||||
|
TimeEffortProfile,
|
||||||
|
)
|
||||||
from app.services.recipe.element_classifier import ElementClassifier
|
from app.services.recipe.element_classifier import ElementClassifier
|
||||||
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
from app.services.recipe.grocery_links import GroceryLinkBuilder
|
||||||
from app.services.recipe.substitution_engine import SubstitutionEngine
|
|
||||||
from app.services.recipe.sensory import SensoryExclude, build_sensory_exclude, passes_sensory_filter
|
|
||||||
from app.services.recipe.time_effort import parse_time_effort
|
|
||||||
from app.services.recipe.reranker import rerank_suggestions
|
from app.services.recipe.reranker import rerank_suggestions
|
||||||
|
from app.services.recipe.sensory import build_sensory_exclude, passes_sensory_filter
|
||||||
|
from app.services.recipe.substitution_engine import SubstitutionEngine
|
||||||
|
from app.services.recipe.time_effort import parse_time_effort
|
||||||
|
|
||||||
_LEFTOVER_DAILY_MAX_FREE = 5
|
_LEFTOVER_DAILY_MAX_FREE = 5
|
||||||
|
|
||||||
|
|
@ -398,9 +406,19 @@ def _expand_pantry_set(
|
||||||
# Extract individual ingredient tokens from multi-word product names.
|
# Extract individual ingredient tokens from multi-word product names.
|
||||||
# "Organic Extra Firm Tofu" → adds "tofu"; "Brown Basmati Rice" → adds "rice".
|
# "Organic Extra Firm Tofu" → adds "tofu"; "Brown Basmati Rice" → adds "rice".
|
||||||
# This catches plain ingredients that _PANTRY_LABEL_SYNONYMS doesn't translate.
|
# This catches plain ingredients that _PANTRY_LABEL_SYNONYMS doesn't translate.
|
||||||
|
# Threshold is 3 chars (not 4) so short-but-meaningful ingredients like "egg",
|
||||||
|
# "oil", and "soy" are captured from packaged product names.
|
||||||
for token in lower.split():
|
for token in lower.split():
|
||||||
if len(token) >= 4 and token not in _PRODUCT_TOKEN_STOPWORDS:
|
if len(token) >= 3 and token not in _PRODUCT_TOKEN_STOPWORDS:
|
||||||
expanded.add(token)
|
expanded.add(token)
|
||||||
|
# Also add singular/plural variant so corpus "egg" matches pantry "eggs"
|
||||||
|
# and vice versa. Simple heuristic: strip/add trailing 's'.
|
||||||
|
if token.endswith("es") and len(token) > 4:
|
||||||
|
expanded.add(token[:-2]) # "tomatoes" → "tomato"
|
||||||
|
elif token.endswith("s") and len(token) > 3:
|
||||||
|
expanded.add(token[:-1]) # "eggs" → "egg", "florets" → "floret"
|
||||||
|
else:
|
||||||
|
expanded.add(token + "s") # "egg" → "eggs", "floret" → "florets"
|
||||||
|
|
||||||
# Secondary state expansion — adds terms like "stale bread", "day-old rice"
|
# Secondary state expansion — adds terms like "stale bread", "day-old rice"
|
||||||
if secondary_pantry_items and item in secondary_pantry_items:
|
if secondary_pantry_items and item in secondary_pantry_items:
|
||||||
|
|
@ -432,14 +450,33 @@ def _ingredient_in_pantry(ingredient: str, pantry_set: set[str]) -> bool:
|
||||||
if pattern in clean and canonical in pantry_set:
|
if pattern in clean and canonical in pantry_set:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Single-token ingredient whose token appears in pantry (e.g. "ketchup" in "c. ketchup")
|
# Single-token or multi-token ingredient whose tokens appear in pantry.
|
||||||
|
# (e.g. "ketchup" in "c. ketchup", "diced onions" when "onion" is in pantry)
|
||||||
|
# Also accepts singular/plural variants so "egg" matches "eggs" and vice versa.
|
||||||
tokens = [t for t in clean.split() if t not in _SWAP_STOPWORDS and len(t) > 2]
|
tokens = [t for t in clean.split() if t not in _SWAP_STOPWORDS and len(t) > 2]
|
||||||
if tokens and all(t in pantry_set for t in tokens):
|
if tokens and all(_token_in_pantry(t, pantry_set) for t in tokens):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _token_in_pantry(token: str, pantry_set: set[str]) -> bool:
|
||||||
|
"""Return True if `token` or a simple plural/singular variant is in pantry_set."""
|
||||||
|
if token in pantry_set:
|
||||||
|
return True
|
||||||
|
# Try adding/removing trailing 's' / 'es' for plural normalization
|
||||||
|
if token.endswith("es") and len(token) > 4:
|
||||||
|
if token[:-2] in pantry_set:
|
||||||
|
return True
|
||||||
|
elif token.endswith("s") and len(token) > 3:
|
||||||
|
if token[:-1] in pantry_set:
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
if (token + "s") in pantry_set:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _content_tokens(text: str) -> frozenset[str]:
|
def _content_tokens(text: str) -> frozenset[str]:
|
||||||
return frozenset(
|
return frozenset(
|
||||||
w for w in text.lower().split()
|
w for w in text.lower().split()
|
||||||
|
|
|
||||||
|
|
@ -152,8 +152,8 @@ def _call_via_local_vlm(image_paths: list[Path], prompt: str) -> str:
|
||||||
raise RuntimeError("No CUDA device -- local VLM unavailable")
|
raise RuntimeError("No CUDA device -- local VLM unavailable")
|
||||||
|
|
||||||
# Lazy import so the module loads fast when GPU is absent
|
# Lazy import so the module loads fast when GPU is absent
|
||||||
from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
|
|
||||||
from PIL import Image, ImageOps
|
from PIL import Image, ImageOps
|
||||||
|
from transformers import AutoProcessor, Qwen2VLForConditionalGeneration
|
||||||
|
|
||||||
model_name = "Qwen/Qwen2.5-VL-7B-Instruct"
|
model_name = "Qwen/Qwen2.5-VL-7B-Instruct"
|
||||||
logger.info("Loading local VLM for recipe scan: %s", model_name)
|
logger.info("Loading local VLM for recipe scan: %s", model_name)
|
||||||
|
|
@ -271,10 +271,11 @@ def _call_vision_backend(
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
if cf_orch_url:
|
if cf_orch_url:
|
||||||
try:
|
try:
|
||||||
from app.services.task_inference import TaskNotRegistered, task_allocate
|
|
||||||
from app.services.ocr.docuvision_client import DocuvisionClient
|
|
||||||
from circuitforge_core.llm.router import LLMRouter
|
from circuitforge_core.llm.router import LLMRouter
|
||||||
|
|
||||||
|
from app.services.ocr.docuvision_client import DocuvisionClient
|
||||||
|
from app.services.task_inference import TaskNotRegistered, task_allocate
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_progress("allocating", "Starting vision service...")
|
_progress("allocating", "Starting vision service...")
|
||||||
with task_allocate("kiwi", "recipe_scan", service_hint="cf-docuvision", ttl_s=120.0) as alloc:
|
with task_allocate("kiwi", "recipe_scan", service_hint="cf-docuvision", ttl_s=120.0) as alloc:
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ Environment:
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
|
|
||||||
from app.models.schemas.recipe import RecipeRequest, RecipeSuggestion
|
from app.models.schemas.recipe import RecipeRequest, RecipeSuggestion
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,9 @@ def _build_router():
|
||||||
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
cf_orch_url = os.environ.get("CF_ORCH_URL")
|
||||||
if cf_orch_url:
|
if cf_orch_url:
|
||||||
try:
|
try:
|
||||||
from app.services.meal_plan.llm_router import _OrchTextRouter # reuse adapter
|
|
||||||
from circuitforge_orch.client import CFOrchClient
|
from circuitforge_orch.client import CFOrchClient
|
||||||
|
|
||||||
|
from app.services.meal_plan.llm_router import _OrchTextRouter # reuse adapter
|
||||||
client = CFOrchClient(cf_orch_url)
|
client = CFOrchClient(cf_orch_url)
|
||||||
ctx = client.allocate(service=_SERVICE_TYPE, ttl_s=_TTL_S, caller=_CALLER)
|
ctx = client.allocate(service=_SERVICE_TYPE, ttl_s=_TTL_S, caller=_CALLER)
|
||||||
alloc = ctx.__enter__()
|
alloc = ctx.__enter__()
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ Powered by:
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Text-signal tables
|
# Text-signal tables
|
||||||
# (tag, [case-insensitive substrings to search in combined title+ingredient text])
|
# (tag, [case-insensitive substrings to search in combined title+ingredient text])
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,11 @@ from pathlib import Path
|
||||||
|
|
||||||
from circuitforge_core.tasks.scheduler import (
|
from circuitforge_core.tasks.scheduler import (
|
||||||
TaskScheduler,
|
TaskScheduler,
|
||||||
|
)
|
||||||
|
from circuitforge_core.tasks.scheduler import (
|
||||||
get_scheduler as _base_get_scheduler,
|
get_scheduler as _base_get_scheduler,
|
||||||
|
)
|
||||||
|
from circuitforge_core.tasks.scheduler import (
|
||||||
reset_scheduler as _reset_local, # re-export for tests
|
reset_scheduler as _reset_local, # re-export for tests
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
20
app/tiers.py
|
|
@ -8,7 +8,7 @@ Uses circuitforge-core can_use() with Kiwi's feature map.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from circuitforge_core.tiers.tiers import can_use as _can_use, BYOK_UNLOCKABLE
|
from circuitforge_core.tiers.tiers import can_use as _can_use
|
||||||
|
|
||||||
# Features that unlock when the user supplies their own LLM backend.
|
# Features that unlock when the user supplies their own LLM backend.
|
||||||
KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
||||||
|
|
@ -49,20 +49,18 @@ KIWI_FEATURES: dict[str, str] = {
|
||||||
"recipe_suggestions": "paid", # BYOK-unlockable
|
"recipe_suggestions": "paid", # BYOK-unlockable
|
||||||
"expiry_llm_matching": "paid", # BYOK-unlockable
|
"expiry_llm_matching": "paid", # BYOK-unlockable
|
||||||
"meal_planning": "free",
|
"meal_planning": "free",
|
||||||
"meal_plan_config": "paid", # configurable meal types (breakfast/lunch/snack)
|
"meal_plan_config": "paid", # configurable meal types (breakfast/lunch/snack)
|
||||||
"meal_plan_llm": "paid", # LLM-assisted full-week plan generation; BYOK-unlockable
|
|
||||||
"meal_plan_llm_timing": "paid", # LLM time fill-in for recipes missing corpus times; BYOK-unlockable
|
|
||||||
"dietary_profiles": "paid",
|
"dietary_profiles": "paid",
|
||||||
"style_picker": "paid",
|
"style_picker": "paid",
|
||||||
"recipe_collections": "paid",
|
"recipe_collections": "paid",
|
||||||
"style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable
|
"community_publish": "paid", # Publish plans/outcomes to community feed
|
||||||
"community_publish": "paid", # Publish plans/outcomes to community feed
|
|
||||||
"community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable)
|
|
||||||
|
|
||||||
# Paid tier (continued)
|
# Premium tier — heavy LLM features (model call per request)
|
||||||
"recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe
|
"meal_plan_llm": "premium", # LLM-assisted full-week plan generation; BYOK-unlockable
|
||||||
|
"meal_plan_llm_timing": "premium", # LLM time fill-in for recipes missing corpus times; BYOK-unlockable
|
||||||
# Premium tier
|
"style_classifier": "premium", # LLM auto-tag for saved recipe style tags; BYOK-unlockable
|
||||||
|
"community_fork_adapt": "premium", # Fork with LLM pantry adaptation; BYOK-unlockable
|
||||||
|
"recipe_scan": "premium", # BYOK-unlockable: photo -> structured recipe
|
||||||
"multi_household": "premium",
|
"multi_household": "premium",
|
||||||
"background_monitoring": "premium",
|
"background_monitoring": "premium",
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,13 @@
|
||||||
# app/utils/progress.py
|
# app/utils/progress.py
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Optional, Callable, Any
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
|
||||||
class ProgressIndicator:
|
class ProgressIndicator:
|
||||||
"""
|
r"""
|
||||||
A simple progress indicator for long-running operations.
|
A simple progress indicator for long-running operations.
|
||||||
|
|
||||||
This class provides different styles of progress indicators:
|
This class provides different styles of progress indicators:
|
||||||
|
|
|
||||||
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>
|
<label class="form-label">Title <span class="form-required">*</span></label>
|
||||||
<input
|
<input
|
||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
class="form-input"
|
:class="['form-input', { 'field-invalid': touched.title && !form.title.trim() }]"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Short summary of the issue or idea"
|
placeholder="Short summary of the issue or idea"
|
||||||
maxlength="120"
|
maxlength="120"
|
||||||
|
@blur="touched.title = true"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="touched.title && !form.title.trim()" class="field-error">Required</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Description <span class="form-required">*</span></label>
|
<label class="form-label">Description <span class="form-required">*</span></label>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
class="form-input feedback-textarea"
|
:class="['form-input', 'feedback-textarea', { 'field-invalid': touched.description && !form.description.trim() }]"
|
||||||
placeholder="Describe what happened or what you'd like to see…"
|
placeholder="Describe what happened or what you'd like to see…"
|
||||||
rows="4"
|
rows="4"
|
||||||
|
@blur="touched.description = true"
|
||||||
/>
|
/>
|
||||||
|
<p v-if="touched.description && !form.description.trim()" class="field-error">Required</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="form.type === 'bug'" class="form-group">
|
<div v-if="form.type === 'bug'" class="form-group">
|
||||||
|
|
@ -135,6 +139,8 @@
|
||||||
v-if="step === 1"
|
v-if="step === 1"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="nextStep"
|
@click="nextStep"
|
||||||
|
:disabled="!canProceed"
|
||||||
|
:title="!canProceed ? 'Fill in Title and Description to continue' : undefined"
|
||||||
>Next →</button>
|
>Next →</button>
|
||||||
<button
|
<button
|
||||||
v-if="step === 2 && !submitted"
|
v-if="step === 2 && !submitted"
|
||||||
|
|
@ -151,7 +157,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, reactive } from 'vue'
|
||||||
|
|
||||||
const props = defineProps<{ currentTab?: string }>()
|
const props = defineProps<{ currentTab?: string }>()
|
||||||
|
|
||||||
|
|
@ -215,6 +221,9 @@ const form = ref({
|
||||||
submitter: '',
|
submitter: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const touched = reactive({ title: false, description: false })
|
||||||
|
const canProceed = computed(() => !!form.value.title.trim() && !!form.value.description.trim())
|
||||||
|
|
||||||
const typeLabel = computed(() => types.find(t => t.value === form.value.type)?.label ?? '')
|
const typeLabel = computed(() => types.find(t => t.value === form.value.type)?.label ?? '')
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
|
@ -231,13 +240,16 @@ function reset() {
|
||||||
submitted.value = false
|
submitted.value = false
|
||||||
issueUrl.value = ''
|
issueUrl.value = ''
|
||||||
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
|
form.value = { type: 'bug', title: '', description: '', repro: '', submitter: '' }
|
||||||
|
touched.title = false
|
||||||
|
touched.description = false
|
||||||
clearScreenshot()
|
clearScreenshot()
|
||||||
}
|
}
|
||||||
|
|
||||||
function nextStep() {
|
function nextStep() {
|
||||||
|
touched.title = true
|
||||||
|
touched.description = true
|
||||||
stepError.value = ''
|
stepError.value = ''
|
||||||
if (!form.value.title.trim() || !form.value.description.trim()) {
|
if (!canProceed.value) {
|
||||||
stepError.value = 'Please fill in both Title and Description.'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
step.value = 2
|
step.value = 2
|
||||||
|
|
@ -297,7 +309,7 @@ async function submit() {
|
||||||
.feedback-fab {
|
.feedback-fab {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: var(--spacing-md);
|
right: var(--spacing-md);
|
||||||
bottom: calc(68px + var(--spacing-md)); /* above mobile bottom nav */
|
bottom: calc(68px + env(safe-area-inset-bottom, 0px) + var(--spacing-md)); /* above mobile bottom nav + system gesture nav */
|
||||||
z-index: 190;
|
z-index: 190;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -326,7 +338,7 @@ async function submit() {
|
||||||
/* On desktop, bottom nav is gone — drop to standard corner */
|
/* On desktop, bottom nav is gone — drop to standard corner */
|
||||||
@media (min-width: 769px) {
|
@media (min-width: 769px) {
|
||||||
.feedback-fab {
|
.feedback-fab {
|
||||||
bottom: var(--spacing-lg);
|
bottom: calc(var(--spacing-lg) + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -413,10 +425,18 @@ async function submit() {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
gap: var(--spacing-sm);
|
gap: var(--spacing-sm);
|
||||||
padding: var(--spacing-sm) var(--spacing-md);
|
padding: var(--spacing-sm) var(--spacing-md);
|
||||||
|
padding-bottom: calc(var(--spacing-sm) + env(safe-area-inset-bottom, 0px)); /* clear home indicator on mobile bottom sheet */
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* When modal is centered (not bottom sheet), safe-area padding isn't needed */
|
||||||
|
@media (min-width: 500px) {
|
||||||
|
.feedback-footer {
|
||||||
|
padding-bottom: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.feedback-textarea {
|
.feedback-textarea {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
min-height: 80px;
|
min-height: 80px;
|
||||||
|
|
@ -426,6 +446,21 @@ async function submit() {
|
||||||
|
|
||||||
.form-required { color: var(--color-error); margin-left: 2px; }
|
.form-required { color: var(--color-error); margin-left: 2px; }
|
||||||
|
|
||||||
|
.field-invalid {
|
||||||
|
border-color: var(--color-error) !important;
|
||||||
|
background: color-mix(in srgb, var(--color-error) 5%, var(--color-bg-secondary));
|
||||||
|
}
|
||||||
|
.field-invalid:focus {
|
||||||
|
border-color: var(--color-error) !important;
|
||||||
|
outline-color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-error {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
margin: 2px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
.feedback-error {
|
.feedback-error {
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
font-size: var(--font-size-sm);
|
font-size: var(--font-size-sm);
|
||||||
|
|
|
||||||
|
|
@ -382,6 +382,7 @@ export const inventoryAPI = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data',
|
'Content-Type': 'multipart/form-data',
|
||||||
},
|
},
|
||||||
|
timeout: 90000, // OFF lookup + LLM expiry prediction can take 30-60s on cold start
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
|
||||||
11
mkdocs.yml
|
|
@ -9,14 +9,14 @@ theme:
|
||||||
name: material
|
name: material
|
||||||
palette:
|
palette:
|
||||||
- scheme: default
|
- scheme: default
|
||||||
primary: light green
|
primary: green
|
||||||
accent: green
|
accent: teal
|
||||||
toggle:
|
toggle:
|
||||||
icon: material/brightness-7
|
icon: material/brightness-7
|
||||||
name: Switch to dark mode
|
name: Switch to dark mode
|
||||||
- scheme: slate
|
- scheme: slate
|
||||||
primary: light green
|
primary: green
|
||||||
accent: green
|
accent: lime
|
||||||
toggle:
|
toggle:
|
||||||
icon: material/brightness-4
|
icon: material/brightness-4
|
||||||
name: Switch to light mode
|
name: Switch to light mode
|
||||||
|
|
@ -64,5 +64,8 @@ nav:
|
||||||
- Tier System: reference/tier-system.md
|
- Tier System: reference/tier-system.md
|
||||||
- Architecture: reference/architecture.md
|
- Architecture: reference/architecture.md
|
||||||
|
|
||||||
|
extra_css:
|
||||||
|
- stylesheets/theme.css
|
||||||
|
|
||||||
extra_javascript:
|
extra_javascript:
|
||||||
- plausible.js
|
- plausible.js
|
||||||
|
|
|
||||||
|
|
@ -35,3 +35,22 @@ include = ["app*"]
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py311"
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I"]
|
||||||
|
ignore = [
|
||||||
|
"E501", # line length — handled by formatter
|
||||||
|
"E402", # module-import-not-at-top — intentional for conditional/lazy imports in endpoints
|
||||||
|
"W293", # whitespace before newline on blank lines — cosmetic, fixed by formatter
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
|
# tests/: F841 unused variables are the standard mock-patch capture pattern.
|
||||||
|
# E741 ambiguous names and E402 conditional imports are common in test fixtures.
|
||||||
|
"tests/**" = ["F841", "E741", "E702"]
|
||||||
|
# scripts/: E741 ambiguous loop vars are common in data pipeline scripts.
|
||||||
|
"scripts/**" = ["E741"]
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@ def backfill(db_path: Path, batch_size: int = 5000) -> None:
|
||||||
fixed += len(updates)
|
fixed += len(updates)
|
||||||
|
|
||||||
offset += batch_size
|
offset += batch_size
|
||||||
done = offset + len(rows) - (batch_size - len(rows))
|
_done = offset + len(rows) - (batch_size - len(rows)) # noqa: F841
|
||||||
pct = min(100, int((offset / total) * 100))
|
pct = min(100, int((offset / total) * 100))
|
||||||
print(f" {pct:>3}% processed {offset:,} fixed {fixed:,} skipped {skipped:,}", end="\r")
|
print(f" {pct:>3}% processed {offset:,} fixed {fixed:,} skipped {skipped:,}", end="\r")
|
||||||
|
|
||||||
|
|
|
||||||
129
scripts/logo/composite.py
Normal file
|
|
@ -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
|
--flavorgraph-dir /tmp/flavorgraph/input
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ Usage:
|
||||||
--usda-branded data/usda_branded.parquet
|
--usda-branded data/usda_branded.parquet
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
@ -16,7 +17,6 @@ from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
# ── Element derivation rules (threshold-based) ────────────────────────────
|
# ── Element derivation rules (threshold-based) ────────────────────────────
|
||||||
|
|
||||||
_ELEMENT_RULES: list[tuple[str, callable]] = [
|
_ELEMENT_RULES: list[tuple[str, callable]] = [
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ Usage:
|
||||||
--batch-size 10000
|
--batch-size 10000
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ Usage:
|
||||||
--recipepairs-recipes data/pipeline/recipepairs_recipes.parquet
|
--recipepairs-recipes data/pipeline/recipepairs_recipes.parquet
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ Downloads:
|
||||||
- lishuyang/recipepairs (GPL-3.0 ⚠) → data/pipeline/recipepairs.parquet [derive only, don't ship]
|
- lishuyang/recipepairs (GPL-3.0 ⚠) → data/pipeline/recipepairs.parquet [derive only, don't ship]
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
|
@ -19,7 +20,6 @@ from pathlib import Path
|
||||||
from datasets import load_dataset
|
from datasets import load_dataset
|
||||||
from huggingface_hub import hf_hub_download
|
from huggingface_hub import hf_hub_download
|
||||||
|
|
||||||
|
|
||||||
# Standard HuggingFace datasets: (hf_path, split, output_filename)
|
# Standard HuggingFace datasets: (hf_path, split, output_filename)
|
||||||
HF_DATASETS = [
|
HF_DATASETS = [
|
||||||
("corbt/all-recipes", "train", "recipes_allrecipes.parquet"),
|
("corbt/all-recipes", "train", "recipes_allrecipes.parquet"),
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ Usage:
|
||||||
--db /path/to/kiwi.db
|
--db /path/to/kiwi.db
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
|
||||||
from app.services.recipe.tag_inferrer import infer_tags
|
from app.services.recipe.tag_inferrer import infer_tags
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Substitution constraint label mapping
|
# Substitution constraint label mapping
|
||||||
# Keys are what we store in substitution_pairs.constraint_label.
|
# Keys are what we store in substitution_pairs.constraint_label.
|
||||||
|
|
|
||||||
|
|
@ -15,11 +15,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
|
@ -98,7 +97,7 @@ def ingest(db_path: Path, parquet_path: Path) -> None:
|
||||||
|
|
||||||
# Filter to rows with full recipe data
|
# Filter to rows with full recipe data
|
||||||
if "HasFullRecipe" in df.columns:
|
if "HasFullRecipe" in df.columns:
|
||||||
df = df[df["HasFullRecipe"] == True].copy()
|
df = df[df["HasFullRecipe"]].copy()
|
||||||
|
|
||||||
if df.empty:
|
if df.empty:
|
||||||
print("No full recipes found in parquet — nothing to ingest.")
|
print("No full recipes found in parquet — nothing to ingest.")
|
||||||
|
|
|
||||||
|
|
@ -109,11 +109,11 @@ def main() -> None:
|
||||||
for s in slugs:
|
for s in slugs:
|
||||||
print(f" {s}")
|
print(f" {s}")
|
||||||
print(f"\nSaved {len(df_new)} total slugs (accumulated) to {args.out}")
|
print(f"\nSaved {len(df_new)} total slugs (accumulated) to {args.out}")
|
||||||
print(f"\nTo scrape full recipes:")
|
print("\nTo scrape full recipes:")
|
||||||
print(f" conda run -n cf python3 scripts/pipeline/purple_carrot/scrape_live.py \\")
|
print(" conda run -n cf python3 scripts/pipeline/purple_carrot/scrape_live.py \\")
|
||||||
print(f" --slugs-from {args.out} \\")
|
print(f" --slugs-from {args.out} \\")
|
||||||
print(f" --out /Library/Assets/kiwi/pipeline/recipes_purplecarrot_live.parquet \\")
|
print(" --out /Library/Assets/kiwi/pipeline/recipes_purplecarrot_live.parquet \\")
|
||||||
print(f" --resume")
|
print(" --resume")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ import logging
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,14 @@ Usage:
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
from playwright.sync_api import sync_playwright, Page, TimeoutError as PWTimeout
|
from playwright.sync_api import Page, sync_playwright
|
||||||
|
from playwright.sync_api import TimeoutError as PWTimeout
|
||||||
|
|
||||||
# ── Config ─────────────────────────────────────────────────────────────────────
|
# ── Config ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
"""Tests for active_min/passive_min fields on browse endpoint responses."""
|
"""Tests for active_min/passive_min fields on browse endpoint responses."""
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
from app.services.recipe.time_effort import parse_time_effort
|
from app.services.recipe.time_effort import parse_time_effort
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -92,7 +90,6 @@ class TestDetailTimeEffortField:
|
||||||
|
|
||||||
def test_time_effort_field_structure(self):
|
def test_time_effort_field_structure(self):
|
||||||
"""Detail endpoint must return the full TimeEffortProfile shape."""
|
"""Detail endpoint must return the full TimeEffortProfile shape."""
|
||||||
import json
|
|
||||||
from app.services.recipe.time_effort import parse_time_effort
|
from app.services.recipe.time_effort import parse_time_effort
|
||||||
|
|
||||||
directions = [
|
directions = [
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
# tests/api/test_community_endpoints.py
|
# tests/api/test_community_endpoints.py
|
||||||
import pytest
|
from unittest.mock import MagicMock, patch
|
||||||
from unittest.mock import patch, MagicMock
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
from app.main import app
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,10 @@ from __future__ import annotations
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from circuitforge_core.api.feedback import make_feedback_router
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from circuitforge_core.api.feedback import make_feedback_router
|
|
||||||
|
|
||||||
|
|
||||||
# ── Test app factory ──────────────────────────────────────────────────────────
|
# ── Test app factory ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
|
def _make_client(demo_mode_fn: Callable[[], bool] | None = None) -> TestClient:
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,14 @@ Tests for the visual label capture API endpoints (kiwi#79):
|
||||||
GET /api/v1/inventory/scan/text — cache hit + needs_visual_capture flag
|
GET /api/v1/inventory/scan/text — cache hit + needs_visual_capture flag
|
||||||
"""
|
"""
|
||||||
import io
|
import io
|
||||||
import os
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from app.main import app
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.cloud_session import get_session
|
from app.cloud_session import get_session
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
@ -213,7 +212,7 @@ class TestScanTextWithCaptureGating:
|
||||||
|
|
||||||
def _off_patch(self, result):
|
def _off_patch(self, result):
|
||||||
"""Patch OpenFoodFactsService.lookup_product at the class level."""
|
"""Patch OpenFoodFactsService.lookup_product at the class level."""
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock
|
||||||
return patch(
|
return patch(
|
||||||
"app.services.openfoodfacts.OpenFoodFactsService.lookup_product",
|
"app.services.openfoodfacts.OpenFoodFactsService.lookup_product",
|
||||||
new=AsyncMock(return_value=result),
|
new=AsyncMock(return_value=result),
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
"""Integration tests for /api/v1/meal-plans/ endpoints."""
|
"""Integration tests for /api/v1/meal-plans/ endpoints."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ from __future__ import annotations
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
|
|
|
||||||
|
|
@ -4,16 +4,14 @@ VLM calls are mocked at the service level -- no GPU or API key needed.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
from unittest.mock import MagicMock, patch
|
||||||
import json
|
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
from app.cloud_session import get_session
|
from app.cloud_session import get_session
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
from app.main import app
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.cloud_session import get_session
|
from app.cloud_session import get_session
|
||||||
from app.db.session import get_store
|
from app.db.session import get_store
|
||||||
|
from app.main import app
|
||||||
|
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.cloud_session import CloudUser, get_session
|
from app.cloud_session import CloudUser, get_session
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""Tests for captured_products store methods (kiwi#79)."""
|
"""Tests for captured_products store methods (kiwi#79)."""
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.db.store import Store
|
from app.db.store import Store
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import json, pytest
|
import pytest
|
||||||
from tests.services.recipe.test_element_classifier import store_with_profiles
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
2
tests/fixtures/recipe_scan/extract_test.py
vendored
|
|
@ -15,8 +15,8 @@ import os
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PIL import Image, ImageOps
|
|
||||||
import anthropic
|
import anthropic
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
PROMPT = """
|
PROMPT = """
|
||||||
You are extracting a recipe from a photograph of a recipe card, cookbook page, or handwritten note.
|
You are extracting a recipe from a photograph of a recipe card, cookbook page, or handwritten note.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import pytest
|
|
||||||
from pathlib import Path
|
|
||||||
import sys
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parents[2]))
|
sys.path.insert(0, str(Path(__file__).parents[2]))
|
||||||
|
|
||||||
def test_normalize_ingredient_name():
|
def test_normalize_ingredient_name():
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
# tests/services/community/test_ap_compat.py
|
# tests/services/community/test_ap_compat.py
|
||||||
import pytest
|
|
||||||
import json
|
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from app.services.community.ap_compat import post_to_ap_json_ld
|
|
||||||
|
|
||||||
|
from app.services.community.ap_compat import post_to_ap_json_ld
|
||||||
|
|
||||||
POST = {
|
POST = {
|
||||||
"slug": "kiwi-plan-test-pasta-week",
|
"slug": "kiwi-plan-test-pasta-week",
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# tests/services/community/test_community_store.py
|
# tests/services/community/test_community_store.py
|
||||||
# MIT License
|
# MIT License
|
||||||
|
|
||||||
import pytest
|
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from app.services.community.community_store import KiwiCommunityStore, get_or_create_pseudonym
|
from app.services.community.community_store import KiwiCommunityStore, get_or_create_pseudonym
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||