New feature: photograph a recipe card, cookbook page, or handwritten note and have it extracted into a structured, editable recipe. Backend: - POST /recipes/scan: accept 1-4 photos, run VLM extraction, return structured JSON for review (not auto-saved) - POST /recipes/scan/save: persist a reviewed/edited recipe - GET/DELETE /recipes/user: user-created recipe CRUD - Vision backend priority: cf-orch -> local Qwen2.5-VL -> Anthropic BYOK - 503 with clear config hint when no vision backend available - Multi-photo support: facing pages (ingredients/directions) sent together - Pantry cross-reference: marks which ingredients are already on hand - migration 041: user_recipes table (title, servings, cook_time, steps, ingredients JSON, source, pantry_match_pct) - Tier gate: recipe_scan -> paid, BYOK-unlockable Frontend: - "Scan" button in the Recipes tab bar (camera icon) - RecipeScanModal: upload step (drag-drop + file picker, up to 4 photos, live previews), processing step (spinner), review/edit step (all fields inline-editable before save), pantry match badge, warning banner for low-confidence or incomplete scans Tests: 35 new tests (23 unit + 12 API), 404 total passing
101 lines
3.5 KiB
Python
101 lines
3.5 KiB
Python
"""
|
|
Kiwi tier gates.
|
|
|
|
Tiers: free < paid < premium
|
|
(Ultra not used in Kiwi — no human-in-the-loop operations.)
|
|
|
|
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
|
|
|
|
# Features that unlock when the user supplies their own LLM backend.
|
|
KIWI_BYOK_UNLOCKABLE: frozenset[str] = frozenset({
|
|
"recipe_suggestions",
|
|
"expiry_llm_matching",
|
|
"receipt_ocr",
|
|
"recipe_scan",
|
|
"style_classifier",
|
|
"meal_plan_llm",
|
|
"meal_plan_llm_timing",
|
|
"community_fork_adapt",
|
|
})
|
|
|
|
# Sources subject to monthly cf-orch call caps. Subscription-based sources are uncapped.
|
|
LIFETIME_SOURCES: frozenset[str] = frozenset({"lifetime", "founders"})
|
|
|
|
# (source, tier) → monthly cf-orch call allowance
|
|
LIFETIME_ORCH_CAPS: dict[tuple[str, str], int] = {
|
|
("lifetime", "paid"): 60,
|
|
("lifetime", "premium"): 180,
|
|
("founders", "premium"): 300,
|
|
}
|
|
|
|
# Feature → minimum tier required
|
|
KIWI_FEATURES: dict[str, str] = {
|
|
# Free tier
|
|
"inventory_crud": "free",
|
|
"barcode_scan": "free",
|
|
"receipt_upload": "free",
|
|
"expiry_alerts": "free",
|
|
"export_csv": "free",
|
|
"leftover_mode": "free", # Rate-limited at API layer, not tier-gated
|
|
"staple_library": "free",
|
|
|
|
# Paid tier
|
|
"receipt_ocr": "paid", # BYOK-unlockable
|
|
"visual_label_capture": "paid", # Camera capture for unenriched barcodes (kiwi#79)
|
|
"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
|
|
"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)
|
|
|
|
# Paid tier (continued)
|
|
"recipe_scan": "paid", # BYOK-unlockable: photo -> structured recipe
|
|
|
|
# Premium tier
|
|
"multi_household": "premium",
|
|
"background_monitoring": "premium",
|
|
}
|
|
|
|
|
|
def can_use(feature: str, tier: str, has_byok: bool = False) -> bool:
|
|
"""Return True if the given tier can access the feature.
|
|
|
|
The 'local' tier is assigned to dev-bypass and non-cloud sessions —
|
|
it has unrestricted access to all features.
|
|
"""
|
|
if tier == "local":
|
|
return True
|
|
return _can_use(
|
|
feature,
|
|
tier,
|
|
has_byok=has_byok,
|
|
_features=KIWI_FEATURES,
|
|
_byok_unlockable=KIWI_BYOK_UNLOCKABLE,
|
|
)
|
|
|
|
|
|
def require_feature(feature: str, tier: str, has_byok: bool = False) -> None:
|
|
"""Raise ValueError if the tier cannot access the feature."""
|
|
if not can_use(feature, tier, has_byok):
|
|
from circuitforge_core.tiers.tiers import tier_label
|
|
needed = tier_label(
|
|
feature,
|
|
has_byok=has_byok,
|
|
_features=KIWI_FEATURES,
|
|
_byok_unlockable=KIWI_BYOK_UNLOCKABLE,
|
|
)
|
|
raise ValueError(
|
|
f"Feature '{feature}' requires {needed} tier. "
|
|
f"Current tier: {tier}."
|
|
)
|