kiwi/app/tiers.py
pyr0ball 896b4e048c feat: recipe scanner — photo to structured recipe (kiwi#9)
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
2026-04-27 08:23:01 -07:00

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}."
)