When a barcode scan finds no product in FDC/OFF, paid-tier users now see a "Capture label" offer instead of a dead-end "add manually" prompt. Backend: - Migration 036: captured_products local cache table (keyed by barcode, UPSERT on conflict so re-capture refreshes rather than errors) - store.get_captured_product / save_captured_product (with JSON decode for ingredient_names and allergens) - app/services/label_capture.py: wraps cf-core VisionRouter (caption API); graceful fallback to zero-confidence mock when stub/error; JSON fence stripping; confidence clamped to [0,1]; KIWI_LABEL_CAPTURE_MOCK=1 for tests - New schemas: LabelCaptureResponse, LabelConfirmRequest, LabelConfirmResponse - POST /inventory/scan/label-capture — image to extraction (paid+ gate, 403) - POST /inventory/scan/label-confirm — save confirmed product + optional inventory add - Both scan endpoints now: check captured_products cache before FDC/OFF; set needs_visual_capture=True for gap products on paid tier; BarcodeScanResult gains needs_visual_capture field - visual_label_capture feature gate added to tiers.py (paid) Tests: 42 new tests (service, store/migration, API endpoints) — 367 total passing Frontend: - InventoryList.vue: capturePhase state machine (offer => uploading => reviewing) - Offer card appears after scan gap (calm UX: no urgency, Discard always visible) - Review form: pre-populated from extraction; amber label highlights for unread fields (confidence < 0.7); comma-separated ingredients/allergens - api.ts: LabelCaptureResult + LabelConfirmRequest types; captureLabelPhoto() and confirmLabelCapture() API methods
97 lines
3.4 KiB
Python
97 lines
3.4 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",
|
|
"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)
|
|
|
|
# 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}."
|
|
)
|