diff --git a/.env.example b/.env.example index 4b38878..0c35dd7 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/__init__.py b/app/__init__.py index 47ec50f..ac7a5b5 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -4,4 +4,4 @@ Kiwi: Pantry tracking and leftover recipe suggestions. """ __version__ = "0.2.0" -__author__ = "Alan 'pyr0ball' Weinstock" \ No newline at end of file +__author__ = "Alan 'pyr0ball' Weinstock" diff --git a/app/api/endpoints/__init__.py b/app/api/endpoints/__init__.py index ecc2abf..b77ab35 100644 --- a/app/api/endpoints/__init__.py +++ b/app/api/endpoints/__init__.py @@ -1,4 +1,4 @@ # app/api/endpoints/__init__.py """ API endpoint implementations for Kiwi. -""" \ No newline at end of file +""" diff --git a/app/api/endpoints/activitypub.py b/app/api/endpoints/activitypub.py index 6ed1cb1..59e9610 100644 --- a/app/api/endpoints/activitypub.py +++ b/app/api/endpoints/activitypub.py @@ -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 []) diff --git a/app/api/endpoints/community.py b/app/api/endpoints/community.py index 8ca0ca7..b03e30c 100644 --- a/app/api/endpoints/community.py +++ b/app/api/endpoints/community.py @@ -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}" diff --git a/app/api/endpoints/corrections.py b/app/api/endpoints/corrections.py index 8e44824..36a9c2b 100644 --- a/app/api/endpoints/corrections.py +++ b/app/api/endpoints/corrections.py @@ -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") diff --git a/app/api/endpoints/feedback.py b/app/api/endpoints/feedback.py index 305ee11..231f532 100644 --- a/app/api/endpoints/feedback.py +++ b/app/api/endpoints/feedback.py @@ -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( diff --git a/app/api/endpoints/feedback_attach.py b/app/api/endpoints/feedback_attach.py index f28f4aa..6147bd4 100644 --- a/app/api/endpoints/feedback_attach.py +++ b/app/api/endpoints/feedback_attach.py @@ -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.") diff --git a/app/api/endpoints/household.py b/app/api/endpoints/household.py index 0a2ca82..343a845 100644 --- a/app/api/endpoints/household.py +++ b/app/api/endpoints/household.py @@ -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() diff --git a/app/api/endpoints/imitate.py b/app/api/endpoints/imitate.py index 5b08731..7ab401b 100644 --- a/app/api/endpoints/imitate.py +++ b/app/api/endpoints/imitate.py @@ -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() diff --git a/app/api/endpoints/inventory.py b/app/api/endpoints/inventory.py index bb3c34d..91812b4 100644 --- a/app/api/endpoints/inventory.py +++ b/app/api/endpoints/inventory.py @@ -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())) diff --git a/app/api/endpoints/mastodon_oauth.py b/app/api/endpoints/mastodon_oauth.py index 28cc6d1..0fd4b55 100644 --- a/app/api/endpoints/mastodon_oauth.py +++ b/app/api/endpoints/mastodon_oauth.py @@ -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("/") diff --git a/app/api/endpoints/meal_plans.py b/app/api/endpoints/meal_plans.py index 5e9ed0e..f6fe351 100644 --- a/app/api/endpoints/meal_plans.py +++ b/app/api/endpoints/meal_plans.py @@ -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 diff --git a/app/api/endpoints/ocr.py b/app/api/endpoints/ocr.py index 7e496b0..e8c5ed3 100644 --- a/app/api/endpoints/ocr.py +++ b/app/api/endpoints/ocr.py @@ -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 diff --git a/app/api/endpoints/receipts.py b/app/api/endpoints/receipts.py index d8b02d2..898d438 100644 --- a/app/api/endpoints/receipts.py +++ b/app/api/endpoints/receipts.py @@ -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() diff --git a/app/api/endpoints/recipe_scan.py b/app/api/endpoints/recipe_scan.py index 6f6e5e5..593ffcb 100644 --- a/app/api/endpoints/recipe_scan.py +++ b/app/api/endpoints/recipe_scan.py @@ -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."}) diff --git a/app/api/endpoints/recipe_tags.py b/app/api/endpoints/recipe_tags.py index 68a9f30..4e849a1 100644 --- a/app/api/endpoints/recipe_tags.py +++ b/app/api/endpoints/recipe_tags.py @@ -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, diff --git a/app/api/endpoints/recipes.py b/app/api/endpoints/recipes.py index 263e77b..c358b20 100644 --- a/app/api/endpoints/recipes.py +++ b/app/api/endpoints/recipes.py @@ -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] diff --git a/app/api/endpoints/shopping.py b/app/api/endpoints/shopping.py index 971266a..85dac20 100644 --- a/app/api/endpoints/shopping.py +++ b/app/api/endpoints/shopping.py @@ -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], ) diff --git a/app/api/routes.py b/app/api/routes.py index de5caa0..385bcf3 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -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 diff --git a/app/cloud_session.py b/app/cloud_session.py index 2e28a7e..0b440f2 100644 --- a/app/cloud_session.py +++ b/app/cloud_session.py @@ -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__) diff --git a/app/core/__init__.py b/app/core/__init__.py index 778fa17..d49f714 100644 --- a/app/core/__init__.py +++ b/app/core/__init__.py @@ -2,4 +2,4 @@ """ Core components for Kiwi. Contains configuration, dependencies, and other core functionality. -""" \ No newline at end of file +""" diff --git a/app/db/models.py b/app/db/models.py index 542ac1f..44f5907 100644 --- a/app/db/models.py +++ b/app/db/models.py @@ -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", diff --git a/app/db/store.py b/app/db/store.py index 55a2f42..58ce232 100644 --- a/app/db/store.py +++ b/app/db/store.py @@ -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] diff --git a/app/main.py b/app/main.py index d26e7c9..cae04ed 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py index bd67b17..ba86da9 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -2,4 +2,4 @@ """ Data models for Kiwi. Contains domain models and Pydantic schemas. -""" \ No newline at end of file +""" diff --git a/app/models/domain/__init__.py b/app/models/domain/__init__.py index 632ef59..85622c2 100644 --- a/app/models/domain/__init__.py +++ b/app/models/domain/__init__.py @@ -2,4 +2,4 @@ """ Domain models for Kiwi. These represent the core business entities. -""" \ No newline at end of file +""" diff --git a/app/models/schemas/__init__.py b/app/models/schemas/__init__.py index 2cc146c..bfca2cb 100644 --- a/app/models/schemas/__init__.py +++ b/app/models/schemas/__init__.py @@ -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"] diff --git a/app/models/schemas/inventory.py b/app/models/schemas/inventory.py index fe42f03..3a36cf4 100644 --- a/app/models/schemas/inventory.py +++ b/app/models/schemas/inventory.py @@ -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): diff --git a/app/models/schemas/label_capture.py b/app/models/schemas/label_capture.py index ea97aae..78eb4e4 100644 --- a/app/models/schemas/label_capture.py +++ b/app/models/schemas/label_capture.py @@ -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 diff --git a/app/models/schemas/meal_plan.py b/app/models/schemas/meal_plan.py index dca28c7..4879fd4 100644 --- a/app/models/schemas/meal_plan.py +++ b/app/models/schemas/meal_plan.py @@ -6,7 +6,6 @@ from datetime import date as _date from pydantic import BaseModel, Field, field_validator - VALID_MEAL_TYPES = {"breakfast", "lunch", "dinner", "snack"} diff --git a/app/models/schemas/ocr.py b/app/models/schemas/ocr.py index 2250ecd..3c0509f 100644 --- a/app/models/schemas/ocr.py +++ b/app/models/schemas/ocr.py @@ -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): diff --git a/app/models/schemas/quality.py b/app/models/schemas/quality.py index d316318..46d40c1 100644 --- a/app/models/schemas/quality.py +++ b/app/models/schemas/quality.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Any, Dict, List + from pydantic import BaseModel diff --git a/app/models/schemas/receipt.py b/app/models/schemas/receipt.py index 9834353..b0a072a 100644 --- a/app/models/schemas/receipt.py +++ b/app/models/schemas/receipt.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Any, Dict, List, Optional + from pydantic import BaseModel, Field diff --git a/app/models/schemas/recipe_scan.py b/app/models/schemas/recipe_scan.py index 41c5e32..5d80be0 100644 --- a/app/models/schemas/recipe_scan.py +++ b/app/models/schemas/recipe_scan.py @@ -9,7 +9,6 @@ from __future__ import annotations from pydantic import BaseModel, Field - # ── Ingredient in a scanned recipe ──────────────────────────────────────────── class ScannedIngredientSchema(BaseModel): diff --git a/app/models/schemas/shopping.py b/app/models/schemas/shopping.py index cffbffb..0b03822 100644 --- a/app/models/schemas/shopping.py +++ b/app/models/schemas/shopping.py @@ -2,6 +2,7 @@ from __future__ import annotations from typing import Optional + from pydantic import BaseModel, Field diff --git a/app/services/barcode_scanner.py b/app/services/barcode_scanner.py index f5f667b..90bc899 100644 --- a/app/services/barcode_scanner.py +++ b/app/services/barcode_scanner.py @@ -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 diff --git a/app/services/expiration_predictor.py b/app/services/expiration_predictor.py index 25ee190..e22fc5c 100644 --- a/app/services/expiration_predictor.py +++ b/app/services/expiration_predictor.py @@ -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__) diff --git a/app/services/export/spreadsheet_export.py b/app/services/export/spreadsheet_export.py index 5901e79..49e9084 100644 --- a/app/services/export/spreadsheet_export.py +++ b/app/services/export/spreadsheet_export.py @@ -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: diff --git a/app/services/image_preprocessing/__init__.py b/app/services/image_preprocessing/__init__.py index 617c1e0..356b204 100644 --- a/app/services/image_preprocessing/__init__.py +++ b/app/services/image_preprocessing/__init__.py @@ -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"] \ No newline at end of file +__all__ = ["convert_to_standard_format", "extract_metadata", "enhance_image", "correct_perspective"] diff --git a/app/services/image_preprocessing/enhancement.py b/app/services/image_preprocessing/enhancement.py index 9080315..abdc712 100644 --- a/app/services/image_preprocessing/enhancement.py +++ b/app/services/image_preprocessing/enhancement.py @@ -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 \ No newline at end of file + return False, f"Error correcting perspective: {str(e)}", None diff --git a/app/services/image_preprocessing/format_conversion.py b/app/services/image_preprocessing/format_conversion.py index 7da5fd5..730bfde 100644 --- a/app/services/image_preprocessing/format_conversion.py +++ b/app/services/image_preprocessing/format_conversion.py @@ -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 \ No newline at end of file + + return metadata diff --git a/app/services/inventory_service.py b/app/services/inventory_service.py index 1993481..747b2de 100644 --- a/app/services/inventory_service.py +++ b/app/services/inventory_service.py @@ -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__) diff --git a/app/services/leftovers_predictor.py b/app/services/leftovers_predictor.py index d30583d..6e58fe0 100644 --- a/app/services/leftovers_predictor.py +++ b/app/services/leftovers_predictor.py @@ -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__) diff --git a/app/services/meal_plan/affiliates.py b/app/services/meal_plan/affiliates.py index b8085f7..fe03a90 100644 --- a/app/services/meal_plan/affiliates.py +++ b/app/services/meal_plan/affiliates.py @@ -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: diff --git a/app/services/ocr/vl_model.py b/app/services/ocr/vl_model.py index df615c8..95042b4 100644 --- a/app/services/ocr/vl_model.py +++ b/app/services/ocr/vl_model.py @@ -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) diff --git a/app/services/openfoodfacts.py b/app/services/openfoodfacts.py index 409137a..d3e3cc6 100644 --- a/app/services/openfoodfacts.py +++ b/app/services/openfoodfacts.py @@ -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__) diff --git a/app/services/quality/__init__.py b/app/services/quality/__init__.py index 5a873fc..698c7e2 100644 --- a/app/services/quality/__init__.py +++ b/app/services/quality/__init__.py @@ -6,4 +6,4 @@ Contains functionality for evaluating receipt image quality. from app.services.quality.assessment import QualityAssessor -__all__ = ["QualityAssessor"] \ No newline at end of file +__all__ = ["QualityAssessor"] diff --git a/app/services/quality/assessment.py b/app/services/quality/assessment.py index 73aea1f..076ff4a 100644 --- a/app/services/quality/assessment.py +++ b/app/services/quality/assessment.py @@ -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 \ No newline at end of file + + return suggestions diff --git a/app/services/receipt_service_inmemory_backup.py b/app/services/receipt_service_inmemory_backup.py index b47afa9..8d0967d 100644 --- a/app/services/receipt_service_inmemory_backup.py +++ b/app/services/receipt_service_inmemory_backup.py @@ -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) \ No newline at end of file + self.receipts[receipt_id]["error"] = str(e) diff --git a/app/services/recipe/assembly_recipes.py b/app/services/recipe/assembly_recipes.py index c68e6ca..39379c3 100644 --- a/app/services/recipe/assembly_recipes.py +++ b/app/services/recipe/assembly_recipes.py @@ -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 diff --git a/app/services/recipe/recipe_engine.py b/app/services/recipe/recipe_engine.py index 6d8b901..f0db00c 100644 --- a/app/services/recipe/recipe_engine.py +++ b/app/services/recipe/recipe_engine.py @@ -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() diff --git a/app/services/recipe/recipe_scanner.py b/app/services/recipe/recipe_scanner.py index b6a526b..bd18280 100644 --- a/app/services/recipe/recipe_scanner.py +++ b/app/services/recipe/recipe_scanner.py @@ -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: diff --git a/app/services/recipe/reranker.py b/app/services/recipe/reranker.py index 76cbda4..8c17570 100644 --- a/app/services/recipe/reranker.py +++ b/app/services/recipe/reranker.py @@ -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 diff --git a/app/services/recipe/style_classifier.py b/app/services/recipe/style_classifier.py index 269c47a..a7c3a74 100644 --- a/app/services/recipe/style_classifier.py +++ b/app/services/recipe/style_classifier.py @@ -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__() diff --git a/app/services/recipe/substitution_engine.py b/app/services/recipe/substitution_engine.py index 1ae302b..af38ebd 100644 --- a/app/services/recipe/substitution_engine.py +++ b/app/services/recipe/substitution_engine.py @@ -7,7 +7,6 @@ Powered by: """ from __future__ import annotations -import json from dataclasses import dataclass, field from typing import TYPE_CHECKING diff --git a/app/services/recipe/tag_inferrer.py b/app/services/recipe/tag_inferrer.py index db027d9..80ba4a6 100644 --- a/app/services/recipe/tag_inferrer.py +++ b/app/services/recipe/tag_inferrer.py @@ -24,7 +24,6 @@ from __future__ import annotations import re - # --------------------------------------------------------------------------- # Text-signal tables # (tag, [case-insensitive substrings to search in combined title+ingredient text]) diff --git a/app/tasks/scheduler.py b/app/tasks/scheduler.py index 9ba6c86..ff13aa2 100644 --- a/app/tasks/scheduler.py +++ b/app/tasks/scheduler.py @@ -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 ) diff --git a/app/tiers.py b/app/tiers.py index 9cb321c..a35698a 100644 --- a/app/tiers.py +++ b/app/tiers.py @@ -8,7 +8,7 @@ Uses circuitforge-core can_use() with Kiwi's feature map. """ from __future__ import annotations -from circuitforge_core.tiers.tiers import can_use as _can_use, BYOK_UNLOCKABLE +from circuitforge_core.tiers.tiers import can_use as _can_use # Features that unlock when the user supplies their own LLM backend. KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({ @@ -49,20 +49,18 @@ KIWI_FEATURES: dict[str, str] = { "recipe_suggestions": "paid", # BYOK-unlockable "expiry_llm_matching": "paid", # BYOK-unlockable "meal_planning": "free", - "meal_plan_config": "paid", # configurable meal types (breakfast/lunch/snack) - "meal_plan_llm": "paid", # LLM-assisted full-week plan generation; BYOK-unlockable - "meal_plan_llm_timing": "paid", # LLM time fill-in for recipes missing corpus times; BYOK-unlockable + "meal_plan_config": "paid", # configurable meal types (breakfast/lunch/snack) "dietary_profiles": "paid", "style_picker": "paid", "recipe_collections": "paid", - "style_classifier": "paid", # LLM auto-tag for saved recipe style tags; BYOK-unlockable - "community_publish": "paid", # Publish plans/outcomes to community feed - "community_fork_adapt": "paid", # Fork with LLM pantry adaptation (BYOK-unlockable) + "community_publish": "paid", # Publish plans/outcomes to community feed - # Paid tier (continued) - "recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe - - # Premium tier + # Premium tier — heavy LLM features (model call per request) + "meal_plan_llm": "premium", # LLM-assisted full-week plan generation; BYOK-unlockable + "meal_plan_llm_timing": "premium", # LLM time fill-in for recipes missing corpus times; BYOK-unlockable + "style_classifier": "premium", # LLM auto-tag for saved recipe style tags; BYOK-unlockable + "community_fork_adapt": "premium", # Fork with LLM pantry adaptation; BYOK-unlockable + "recipe_scan": "premium", # BYOK-unlockable: photo -> structured recipe "multi_household": "premium", "background_monitoring": "premium", } diff --git a/app/utils/__init__.py b/app/utils/__init__.py index 0777f75..405dc1e 100644 --- a/app/utils/__init__.py +++ b/app/utils/__init__.py @@ -2,4 +2,4 @@ """ Utility functions for Kiwi. Contains common helpers used throughout the application. -""" \ No newline at end of file +""" diff --git a/app/utils/progress.py b/app/utils/progress.py index a8aca53..f5288bb 100644 --- a/app/utils/progress.py +++ b/app/utils/progress.py @@ -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() \ No newline at end of file + await progress.stop_async() diff --git a/docs/stylesheets/theme.css b/docs/stylesheets/theme.css new file mode 100644 index 0000000..d3c7068 --- /dev/null +++ b/docs/stylesheets/theme.css @@ -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; +} diff --git a/frontend/public/favicon-64.png b/frontend/public/favicon-64.png new file mode 100644 index 0000000..1ee9c51 Binary files /dev/null and b/frontend/public/favicon-64.png differ diff --git a/frontend/public/icon-192.png b/frontend/public/icon-192.png new file mode 100644 index 0000000..69de8b8 Binary files /dev/null and b/frontend/public/icon-192.png differ diff --git a/frontend/public/icon-512.png b/frontend/public/icon-512.png new file mode 100644 index 0000000..63f584f Binary files /dev/null and b/frontend/public/icon-512.png differ diff --git a/frontend/public/icons/icon-192.png b/frontend/public/icons/icon-192.png index bb1fe86..69de8b8 100644 Binary files a/frontend/public/icons/icon-192.png and b/frontend/public/icons/icon-192.png differ diff --git a/frontend/public/icons/icon-512.png b/frontend/public/icons/icon-512.png index b33e4bf..63f584f 100644 Binary files a/frontend/public/icons/icon-512.png and b/frontend/public/icons/icon-512.png differ diff --git a/frontend/public/icons/maskable-192.png b/frontend/public/icons/maskable-192.png index 9ad8a96..69de8b8 100644 Binary files a/frontend/public/icons/maskable-192.png and b/frontend/public/icons/maskable-192.png differ diff --git a/frontend/public/icons/maskable-512.png b/frontend/public/icons/maskable-512.png index 1904d77..63f584f 100644 Binary files a/frontend/public/icons/maskable-512.png and b/frontend/public/icons/maskable-512.png differ diff --git a/frontend/src/components/FeedbackButton.vue b/frontend/src/components/FeedbackButton.vue index 6d75a27..132f86f 100644 --- a/frontend/src/components/FeedbackButton.vue +++ b/frontend/src/components/FeedbackButton.vue @@ -48,21 +48,25 @@ +
Required
Required