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

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

View file

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

View file

@ -4,4 +4,4 @@ Kiwi: Pantry tracking and leftover recipe suggestions.
"""
__version__ = "0.2.0"
__author__ = "Alan 'pyr0ball' Weinstock"
__author__ = "Alan 'pyr0ball' Weinstock"

View file

@ -1,4 +1,4 @@
# app/api/endpoints/__init__.py
"""
API endpoint implementations for Kiwi.
"""
"""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,4 +2,4 @@
"""
Core components for Kiwi.
Contains configuration, dependencies, and other core functionality.
"""
"""

View file

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

View file

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

View file

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

View file

@ -2,4 +2,4 @@
"""
Data models for Kiwi.
Contains domain models and Pydantic schemas.
"""
"""

View file

@ -2,4 +2,4 @@
"""
Domain models for Kiwi.
These represent the core business entities.
"""
"""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,10 +1,11 @@
#!/usr/bin/env python
# app/services/image_preprocessing/
import cv2
import numpy as np
import logging
from pathlib import Path
from typing import Tuple, Optional
from typing import Optional, Tuple
import cv2
import numpy as np
logger = logging.getLogger(__name__)
@ -29,19 +30,19 @@ def enhance_image(
try:
# Check if CUDA is available
use_cuda = cv2.cuda.getCudaEnabledDeviceCount() > 0
# Set output path if not provided
if output_path is None:
output_path = image_path.with_stem(f"{image_path.stem}_enhanced")
# Read image
img = cv2.imread(str(image_path))
if img is None:
return False, f"Failed to read image: {image_path}", None
# Convert to grayscale
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Apply denoising if requested
if denoise:
if use_cuda:
@ -55,29 +56,29 @@ def enhance_image(
denoised = cv2.fastNlMeansDenoising(gray, None, 10, 7, 21)
else:
denoised = gray
# Apply adaptive thresholding if requested
if adaptive_threshold:
# Adaptive thresholding works well for receipts with varying backgrounds
binary = cv2.adaptiveThreshold(
denoised,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
11,
denoised,
255,
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY,
11,
2
)
processed = binary
else:
processed = denoised
# Write enhanced image
success = cv2.imwrite(str(output_path), processed)
if not success:
return False, f"Failed to write enhanced image to {output_path}", None
return True, "Image enhanced successfully", output_path
except Exception as e:
logger.exception(f"Error enhancing image: {e}")
return False, f"Error enhancing image: {str(e)}", None
@ -100,40 +101,40 @@ def correct_perspective(
# Set output path if not provided
if output_path is None:
output_path = image_path.with_stem(f"{image_path.stem}_perspective")
# Read image
img = cv2.imread(str(image_path))
if img is None:
return False, f"Failed to read image: {image_path}", None
# Convert to grayscale
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Apply Gaussian blur to reduce noise
blur = cv2.GaussianBlur(gray, (5, 5), 0)
# Apply edge detection
edges = cv2.Canny(blur, 50, 150, apertureSize=3)
# Find contours
contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
# Find the largest contour by area which is likely the receipt
if not contours:
return False, "No contours found in image", None
largest_contour = max(contours, key=cv2.contourArea)
# Approximate the contour to get the corners
epsilon = 0.02 * cv2.arcLength(largest_contour, True)
approx = cv2.approxPolyDP(largest_contour, epsilon, True)
# If we have a quadrilateral, we can apply perspective transform
if len(approx) == 4:
# Sort the points for the perspective transform
# This is a simplified implementation
src_pts = approx.reshape(4, 2).astype(np.float32)
# Get width and height for the destination image
width = int(max(
np.linalg.norm(src_pts[0] - src_pts[1]),
@ -143,7 +144,7 @@ def correct_perspective(
np.linalg.norm(src_pts[0] - src_pts[3]),
np.linalg.norm(src_pts[1] - src_pts[2])
))
# Define destination points
dst_pts = np.array([
[0, 0],
@ -151,22 +152,22 @@ def correct_perspective(
[width - 1, height - 1],
[0, height - 1]
], dtype=np.float32)
# Get perspective transform matrix
M = cv2.getPerspectiveTransform(src_pts, dst_pts)
# Apply perspective transform
warped = cv2.warpPerspective(img, M, (width, height))
# Write corrected image
success = cv2.imwrite(str(output_path), warped)
if not success:
return False, f"Failed to write perspective-corrected image to {output_path}", None
return True, "Perspective corrected successfully", output_path
else:
return False, "Receipt corners not clearly detected", None
except Exception as e:
logger.exception(f"Error correcting perspective: {e}")
return False, f"Error correcting perspective: {str(e)}", None
return False, f"Error correcting perspective: {str(e)}", None

View file

@ -1,10 +1,10 @@
#!/usr/bin/env python
# app/services/image_preprocessing/format_conversion.py
import cv2
import numpy as np
import logging
from pathlib import Path
from typing import Tuple, Optional
from typing import Optional, Tuple
import cv2
logger = logging.getLogger(__name__)
@ -28,33 +28,33 @@ def convert_to_standard_format(
# Check if CUDA is available and set up GPU processing
if cv2.cuda.getCudaEnabledDeviceCount() > 0:
logger.info("CUDA available, using GPU acceleration")
use_cuda = True
use_cuda = True # noqa: F841
else:
logger.info("CUDA not available, using CPU processing")
use_cuda = False
use_cuda = False # noqa: F841
# Read image
img = cv2.imread(str(image_path))
if img is None:
return False, f"Failed to read image: {image_path}", None
# If PDF, extract first page (simplified for Phase 1)
if image_path.suffix.lower() == '.pdf':
# This is a placeholder for PDF handling
# In a real implementation, you'd use a PDF processing library
return False, "PDF processing not implemented in Phase 1", None
# Set output path if not provided
if output_path is None:
output_path = image_path.with_suffix(f".{target_format}")
# Write converted image
success = cv2.imwrite(str(output_path), img)
if not success:
return False, f"Failed to write converted image to {output_path}", None
return True, "Image converted successfully", output_path
except Exception as e:
logger.exception(f"Error converting image: {e}")
return False, f"Error converting image: {str(e)}", None
@ -74,7 +74,7 @@ def extract_metadata(image_path: Path) -> dict:
"original_format": image_path.suffix.lstrip(".").lower(),
"file_size_bytes": image_path.stat().st_size,
}
try:
img = cv2.imread(str(image_path))
if img is not None:
@ -85,5 +85,5 @@ def extract_metadata(image_path: Path) -> dict:
})
except Exception as e:
logger.exception(f"Error extracting image metadata: {e}")
return metadata
return metadata

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -6,4 +6,4 @@ Contains functionality for evaluating receipt image quality.
from app.services.quality.assessment import QualityAssessor
__all__ = ["QualityAssessor"]
__all__ = ["QualityAssessor"]

View file

@ -1,10 +1,11 @@
#!/usr/bin/env python
# app/services/quality/assessment.py
import cv2
import numpy as np
import logging
from pathlib import Path
from typing import Dict, Any, Optional, Tuple
from typing import Any, Dict, Tuple
import cv2
import numpy as np
logger = logging.getLogger(__name__)
@ -12,7 +13,7 @@ class QualityAssessor:
"""
Assesses the quality of receipt images for processing suitability.
"""
def __init__(self, min_quality_score: float = 50.0):
"""
Initialize the quality assessor.
@ -21,7 +22,7 @@ class QualityAssessor:
min_quality_score: Minimum acceptable quality score (0-100)
"""
self.min_quality_score = min_quality_score
def assess_image(self, image_path: Path) -> Dict[str, Any]:
"""
Assess the quality of an image.
@ -41,19 +42,19 @@ class QualityAssessor:
"error": f"Failed to read image: {image_path}",
"overall_score": 0.0,
}
# Convert to grayscale for some metrics
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Calculate various quality metrics
blur_score = self._calculate_blur_score(gray)
lighting_score = self._calculate_lighting_score(gray)
contrast_score = self._calculate_contrast_score(gray)
size_score = self._calculate_size_score(img.shape)
# Check for potential fold lines
fold_detected, fold_severity = self._detect_folds(gray)
# Calculate overall quality score
overall_score = self._calculate_overall_score({
"blur": blur_score,
@ -62,7 +63,7 @@ class QualityAssessor:
"size": size_score,
"fold": 100.0 if not fold_detected else (100.0 - fold_severity * 20.0)
})
# Create assessment result
result = {
"success": True,
@ -85,9 +86,9 @@ class QualityAssessor:
"fold_severity": fold_severity if fold_detected else 0.0,
}),
}
return result
except Exception as e:
logger.exception(f"Error assessing image quality: {e}")
return {
@ -95,7 +96,7 @@ class QualityAssessor:
"error": f"Error assessing image quality: {str(e)}",
"overall_score": 0.0,
}
def _calculate_blur_score(self, gray_img: np.ndarray) -> float:
"""
Calculate blur score using Laplacian variance.
@ -109,10 +110,10 @@ class QualityAssessor:
"""
# Use Laplacian for edge detection
laplacian = cv2.Laplacian(gray_img, cv2.CV_64F)
# Calculate variance of Laplacian
variance = laplacian.var()
# Map variance to a 0-100 score
# These thresholds might need adjustment based on your specific requirements
if variance < 10:
@ -123,7 +124,7 @@ class QualityAssessor:
return 50 + (variance - 100) / 900 * 50 # Map 100-1000 to 50-100
else:
return 100.0 # Very sharp
def _calculate_lighting_score(self, gray_img: np.ndarray) -> float:
"""
Calculate lighting score based on average brightness and std dev.
@ -136,21 +137,21 @@ class QualityAssessor:
"""
# Calculate mean brightness
mean = gray_img.mean()
# Calculate standard deviation of brightness
std = gray_img.std()
# Ideal mean would be around 127 (middle of 0-255)
# Penalize if too dark or too bright
mean_score = 100 - abs(mean - 127) / 127 * 100
# Higher std dev generally means better contrast
# But we'll cap at 60 for reasonable balance
std_score = min(std / 60 * 100, 100)
# Combine scores (weighted)
return 0.6 * mean_score + 0.4 * std_score
def _calculate_contrast_score(self, gray_img: np.ndarray) -> float:
"""
Calculate contrast score.
@ -163,13 +164,13 @@ class QualityAssessor:
"""
# Calculate histogram
hist = cv2.calcHist([gray_img], [0], None, [256], [0, 256])
# Calculate percentage of pixels in each brightness range
total_pixels = gray_img.shape[0] * gray_img.shape[1]
dark_pixels = np.sum(hist[:50]) / total_pixels
mid_pixels = np.sum(hist[50:200]) / total_pixels
bright_pixels = np.sum(hist[200:]) / total_pixels
# Ideal: good distribution across ranges with emphasis on mid-range
# This is a simplified model - real receipts may need different distributions
score = (
@ -177,9 +178,9 @@ class QualityAssessor:
(0.6 * min(mid_pixels * 200, 100)) + # Want many mid pixels
(0.2 * min(bright_pixels * 500, 100)) # Want some bright pixels (background)
)
return score
def _calculate_size_score(self, shape: Tuple[int, int, int]) -> float:
"""
Calculate score based on image dimensions.
@ -191,10 +192,10 @@ class QualityAssessor:
Size score (0-100)
"""
height, width = shape[0], shape[1]
# Minimum recommended dimensions for good OCR
min_height, min_width = 800, 600
# Calculate size score
if height < min_height or width < min_width:
# Penalize if below minimum dimensions
@ -202,7 +203,7 @@ class QualityAssessor:
else:
# Full score if dimensions are adequate
return 100.0
def _detect_folds(self, gray_img: np.ndarray) -> Tuple[bool, float]:
"""
Detect potential fold lines in the image.
@ -216,37 +217,37 @@ class QualityAssessor:
"""
# Apply edge detection
edges = cv2.Canny(gray_img, 50, 150, apertureSize=3)
# Apply Hough Line Transform to detect straight lines
lines = cv2.HoughLinesP(
edges,
rho=1,
theta=np.pi/180,
threshold=100,
edges,
rho=1,
theta=np.pi/180,
threshold=100,
minLineLength=gray_img.shape[1] // 3, # Look for lines at least 1/3 of image width
maxLineGap=10
)
if lines is None:
return False, 0.0
# Check for horizontal or vertical lines that could be folds
potential_folds = []
height, width = gray_img.shape
for line in lines:
x1, y1, x2, y2 = line[0]
length = np.sqrt((x2 - x1)**2 + (y2 - y1)**2)
angle = np.abs(np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi)
# Check if horizontal (0±10°) or vertical (90±10°)
is_horizontal = angle < 10 or angle > 170
is_vertical = abs(angle - 90) < 10
# Check if length is significant
is_significant = (is_horizontal and length > width * 0.5) or \
(is_vertical and length > height * 0.5)
if (is_horizontal or is_vertical) and is_significant:
# Calculate intensity variance along the line
# This helps determine if it's a fold (sharp brightness change)
@ -255,11 +256,11 @@ class QualityAssessor:
"length": length,
"is_horizontal": is_horizontal,
})
# Determine if folds are detected and their severity
if not potential_folds:
return False, 0.0
# Severity based on number and length of potential folds
# This is a simplified metric for Phase 1
total_len = sum(fold["length"] for fold in potential_folds)
@ -267,9 +268,9 @@ class QualityAssessor:
severity = min(5.0, total_len / width * 2.5)
else:
severity = min(5.0, total_len / height * 2.5)
return True, severity
def _calculate_overall_score(self, scores: Dict[str, float]) -> float:
"""
Calculate overall quality score from individual metrics.
@ -288,12 +289,12 @@ class QualityAssessor:
"size": 0.10,
"fold": 0.10,
}
# Calculate weighted average
overall = sum(weights[key] * scores[key] for key in weights)
return overall
def _generate_suggestions(self, metrics: Dict[str, Any]) -> list:
"""
Generate improvement suggestions based on metrics.
@ -305,28 +306,28 @@ class QualityAssessor:
List of improvement suggestions
"""
suggestions = []
# Blur suggestions
if metrics["blur"] < 60:
suggestions.append("Hold the camera steady and ensure the receipt is in focus.")
# Lighting suggestions
if metrics["lighting"] < 60:
suggestions.append("Improve lighting conditions and avoid shadows on the receipt.")
# Contrast suggestions
if metrics["contrast"] < 60:
suggestions.append("Ensure good contrast between text and background.")
# Size suggestions
if metrics["size"] < 60:
suggestions.append("Move the camera closer to the receipt for better resolution.")
# Fold suggestions
if metrics["fold"]:
if metrics["fold_severity"] > 3.0:
suggestions.append("The receipt has severe folds. Try to flatten it before capturing.")
else:
suggestions.append("Flatten the receipt to remove fold lines for better processing.")
return suggestions
return suggestions

View file

@ -1,23 +1,22 @@
#!/usr/bin/env python
# app/services/receipt_service.py
import os
import uuid
import shutil
import aiofiles
from pathlib import Path
from typing import Optional, List, Dict, Any
from fastapi import UploadFile, BackgroundTasks, HTTPException
import asyncio
import logging
import sys
from app.utils.progress import ProgressIndicator
import uuid
from pathlib import Path
from typing import Dict, List, Optional
import aiofiles
from fastapi import BackgroundTasks, HTTPException, UploadFile
from app.services.image_preprocessing.format_conversion import convert_to_standard_format, extract_metadata
from app.services.image_preprocessing.enhancement import enhance_image, correct_perspective
from app.services.quality.assessment import QualityAssessor
from app.models.schemas.receipt import ReceiptCreate, ReceiptResponse
from app.models.schemas.quality import QualityAssessment
from app.core.config import settings
from app.models.schemas.quality import QualityAssessment
from app.models.schemas.receipt import ReceiptResponse
from app.services.image_preprocessing.enhancement import correct_perspective, enhance_image
from app.services.image_preprocessing.format_conversion import (
convert_to_standard_format,
extract_metadata,
)
from app.services.quality.assessment import QualityAssessor
logger = logging.getLogger(__name__)
@ -25,7 +24,7 @@ class ReceiptService:
"""
Service for handling receipt processing.
"""
def __init__(self):
"""
Initialize the receipt service.
@ -33,18 +32,18 @@ class ReceiptService:
self.quality_assessor = QualityAssessor()
self.upload_dir = Path(settings.UPLOAD_DIR)
self.processing_dir = Path(settings.PROCESSING_DIR)
# Create directories if they don't exist
self.upload_dir.mkdir(parents=True, exist_ok=True)
self.processing_dir.mkdir(parents=True, exist_ok=True)
# In-memory storage for Phase 1 (would be replaced by DB in production)
self.receipts = {}
self.quality_assessments = {}
async def process_receipt(
self,
file: UploadFile,
self,
file: UploadFile,
background_tasks: BackgroundTasks
) -> ReceiptResponse:
"""
@ -59,11 +58,11 @@ class ReceiptService:
"""
# Generate unique ID for receipt
receipt_id = str(uuid.uuid4())
# Save uploaded file
upload_path = self.upload_dir / f"{receipt_id}_{file.filename}"
await self._save_upload_file(file, upload_path)
# Create receipt entry
receipt = {
"id": receipt_id,
@ -73,16 +72,16 @@ class ReceiptService:
"processed_path": None,
"metadata": {},
}
self.receipts[receipt_id] = receipt
# Add background task for processing
background_tasks.add_task(
self._process_receipt_background,
receipt_id,
upload_path
)
return ReceiptResponse(
id=receipt_id,
filename=file.filename,
@ -90,7 +89,7 @@ class ReceiptService:
metadata={},
quality_score=None,
)
async def get_receipt(self, receipt_id: str) -> Optional[ReceiptResponse]:
"""
Get receipt by ID.
@ -104,10 +103,10 @@ class ReceiptService:
receipt = self.receipts.get(receipt_id)
if not receipt:
return None
quality = self.quality_assessments.get(receipt_id)
quality_score = quality.get("overall_score") if quality else None
return ReceiptResponse(
id=receipt["id"],
filename=receipt["filename"],
@ -115,7 +114,7 @@ class ReceiptService:
metadata=receipt["metadata"],
quality_score=quality_score,
)
async def get_receipt_quality(self, receipt_id: str) -> Optional[QualityAssessment]:
"""
Get quality assessment for a receipt.
@ -129,7 +128,7 @@ class ReceiptService:
quality = self.quality_assessments.get(receipt_id)
if not quality:
return None
return QualityAssessment(
receipt_id=receipt_id,
overall_score=quality["overall_score"],
@ -193,11 +192,11 @@ class ReceiptService:
while content:
await out_file.write(content)
content = await file.read(1024 * 1024)
except Exception as e:
logger.exception(f"Error saving upload file: {e}")
raise HTTPException(status_code=500, detail=f"Error saving upload file: {str(e)}")
async def _process_receipt_background(self, receipt_id: str, upload_path: Path) -> None:
"""
Background task for processing a receipt with progress indicators.
@ -209,65 +208,65 @@ class ReceiptService:
try:
# Print a message to indicate start of processing
print(f"\nProcessing receipt {receipt_id}...")
# Update status
self.receipts[receipt_id]["status"] = "processing"
# Create processing directory for this receipt
receipt_dir = self.processing_dir / receipt_id
receipt_dir.mkdir(parents=True, exist_ok=True)
# Step 1: Convert to standard format
print(" Step 1/4: Converting to standard format...")
converted_path = receipt_dir / f"{receipt_id}_converted.png"
success, message, actual_converted_path = convert_to_standard_format(
upload_path,
upload_path,
converted_path
)
if not success:
print(f" ✗ Format conversion failed: {message}")
self.receipts[receipt_id]["status"] = "error"
self.receipts[receipt_id]["error"] = message
return
print(" ✓ Format conversion complete")
# Step 2: Correct perspective
print(" Step 2/4: Correcting perspective...")
perspective_path = receipt_dir / f"{receipt_id}_perspective.png"
success, message, actual_perspective_path = correct_perspective(
actual_converted_path,
actual_converted_path,
perspective_path
)
# Use corrected image if available, otherwise use converted image
current_path = actual_perspective_path if success else actual_converted_path
if success:
print(" ✓ Perspective correction complete")
else:
print(f" ⚠ Perspective correction skipped: {message}")
# Step 3: Enhance image
print(" Step 3/4: Enhancing image...")
enhanced_path = receipt_dir / f"{receipt_id}_enhanced.png"
success, message, actual_enhanced_path = enhance_image(
current_path,
current_path,
enhanced_path
)
if not success:
print(f" ⚠ Enhancement warning: {message}")
# Continue with current path
else:
current_path = actual_enhanced_path
print(" ✓ Image enhancement complete")
# Step 4: Assess quality
print(" Step 4/4: Assessing quality...")
quality_assessment = self.quality_assessor.assess_image(current_path)
self.quality_assessments[receipt_id] = quality_assessment
print(f" ✓ Quality assessment complete: score {quality_assessment['overall_score']:.1f}/100")
# Step 5: Extract metadata
print(" Extracting metadata...")
metadata = extract_metadata(upload_path)
@ -279,17 +278,17 @@ class ReceiptService:
"format": processed_metadata.get("original_format"),
}
print(" ✓ Metadata extraction complete")
# Update receipt entry
self.receipts[receipt_id].update({
"status": "processed",
"processed_path": str(current_path),
"metadata": metadata,
})
print(f"✓ Receipt {receipt_id} processed successfully!")
except Exception as e:
print(f"✗ Error processing receipt {receipt_id}: {e}")
self.receipts[receipt_id]["status"] = "error"
self.receipts[receipt_id]["error"] = str(e)
self.receipts[receipt_id]["error"] = str(e)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,4 +2,4 @@
"""
Utility functions for Kiwi.
Contains common helpers used throughout the application.
"""
"""

View file

@ -1,12 +1,13 @@
# app/utils/progress.py
import sys
import time
import asyncio
from typing import Optional, Callable, Any
import sys
import threading
import time
from typing import Any, Callable, Optional
class ProgressIndicator:
"""
r"""
A simple progress indicator for long-running operations.
This class provides different styles of progress indicators:
@ -14,10 +15,10 @@ class ProgressIndicator:
- spinner: Spinning cursor (|/-\)
- percentage: Progress percentage [#### ] 40%
"""
def __init__(self,
message: str = "Processing",
style: str = "dots",
def __init__(self,
message: str = "Processing",
style: str = "dots",
total: Optional[int] = None):
"""
Initialize the progress indicator.
@ -35,23 +36,23 @@ class ProgressIndicator:
self._running = False
self._thread = None
self._task = None
# Validate style
if style not in ["dots", "spinner", "percentage"]:
raise ValueError("Style must be 'dots', 'spinner', or 'percentage'")
# Validate total for percentage style
if style == "percentage" and total is None:
raise ValueError("Total must be specified for percentage style")
def start(self):
"""Start the progress indicator in a separate thread."""
if self._running:
return
self._running = True
self.start_time = time.time()
# Start the appropriate indicator
if self.style == "dots":
self._thread = threading.Thread(target=self._dots_indicator)
@ -59,18 +60,18 @@ class ProgressIndicator:
self._thread = threading.Thread(target=self._spinner_indicator)
elif self.style == "percentage":
self._thread = threading.Thread(target=self._percentage_indicator)
self._thread.daemon = True
self._thread.start()
async def start_async(self):
"""Start the progress indicator as an asyncio task."""
if self._running:
return
self._running = True
self.start_time = time.time()
# Start the appropriate indicator
if self.style == "dots":
self._task = asyncio.create_task(self._dots_indicator_async())
@ -78,43 +79,43 @@ class ProgressIndicator:
self._task = asyncio.create_task(self._spinner_indicator_async())
elif self.style == "percentage":
self._task = asyncio.create_task(self._percentage_indicator_async())
def update(self, current: int):
"""Update the progress (for percentage style)."""
self.current = current
def stop(self):
"""Stop the progress indicator."""
if not self._running:
return
self._running = False
if self._thread:
self._thread.join(timeout=1.0)
# Clear the progress line and write a newline
sys.stdout.write("\r" + " " * 80 + "\r")
sys.stdout.flush()
async def stop_async(self):
"""Stop the progress indicator (async version)."""
if not self._running:
return
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
# Clear the progress line and write a newline
sys.stdout.write("\r" + " " * 80 + "\r")
sys.stdout.flush()
def _dots_indicator(self):
"""Display an animated dots indicator."""
i = 0
@ -125,7 +126,7 @@ class ProgressIndicator:
sys.stdout.flush()
time.sleep(0.5)
i += 1
async def _dots_indicator_async(self):
"""Display an animated dots indicator (async version)."""
i = 0
@ -136,7 +137,7 @@ class ProgressIndicator:
sys.stdout.flush()
await asyncio.sleep(0.5)
i += 1
def _spinner_indicator(self):
"""Display a spinning cursor indicator."""
chars = "|/-\\"
@ -148,7 +149,7 @@ class ProgressIndicator:
sys.stdout.flush()
time.sleep(0.1)
i += 1
async def _spinner_indicator_async(self):
"""Display a spinning cursor indicator (async version)."""
chars = "|/-\\"
@ -160,7 +161,7 @@ class ProgressIndicator:
sys.stdout.flush()
await asyncio.sleep(0.1)
i += 1
def _percentage_indicator(self):
"""Display a percentage progress bar."""
while self._running:
@ -169,17 +170,17 @@ class ProgressIndicator:
filled_length = int(bar_length * percentage // 100)
bar = '#' * filled_length + ' ' * (bar_length - filled_length)
elapsed = time.time() - self.start_time
# Estimate time remaining if we have progress
if percentage > 0:
remaining = elapsed * (100 - percentage) / percentage
sys.stdout.write(f"\r{self.message} [{bar}] {percentage}% ({elapsed:.1f}s elapsed, ~{remaining:.1f}s remaining)")
else:
sys.stdout.write(f"\r{self.message} [{bar}] {percentage}% ({elapsed:.1f}s elapsed)")
sys.stdout.flush()
time.sleep(0.2)
async def _percentage_indicator_async(self):
"""Display a percentage progress bar (async version)."""
while self._running:
@ -188,14 +189,14 @@ class ProgressIndicator:
filled_length = int(bar_length * percentage // 100)
bar = '#' * filled_length + ' ' * (bar_length - filled_length)
elapsed = time.time() - self.start_time
# Estimate time remaining if we have progress
if percentage > 0:
remaining = elapsed * (100 - percentage) / percentage
sys.stdout.write(f"\r{self.message} [{bar}] {percentage}% ({elapsed:.1f}s elapsed, ~{remaining:.1f}s remaining)")
else:
sys.stdout.write(f"\r{self.message} [{bar}] {percentage}% ({elapsed:.1f}s elapsed)")
sys.stdout.flush()
await asyncio.sleep(0.2)
@ -216,7 +217,7 @@ def with_progress(func: Callable, *args, message: str = "Processing", style: str
"""
progress = ProgressIndicator(message=message, style=style)
progress.start()
try:
result = func(*args, **kwargs)
return result
@ -240,9 +241,9 @@ async def with_progress_async(func: Callable, *args, message: str = "Processing"
"""
progress = ProgressIndicator(message=message, style=style)
await progress.start_async()
try:
result = await func(*args, **kwargs)
return result
finally:
await progress.stop_async()
await progress.stop_async()

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 139 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

View file

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

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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