pranaysuyash commited on
Commit
ae428ae
·
verified ·
1 Parent(s): d2d3bed

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. README.md +299 -6
  2. app.py +204 -0
  3. packages.txt +1 -0
  4. requirements.txt +10 -0
  5. setup.py +40 -0
  6. shopstack/__init__.py +0 -0
  7. shopstack/_legacy_decisions.py +88 -0
  8. shopstack/_version.py +1 -0
  9. shopstack/app_context.py +105 -0
  10. shopstack/basket/__init__.py +4 -0
  11. shopstack/basket/constraints.py +28 -0
  12. shopstack/basket/models.py +38 -0
  13. shopstack/basket/scoring.py +39 -0
  14. shopstack/basket/service.py +205 -0
  15. shopstack/catalog/models.py +33 -0
  16. shopstack/catalog/service.py +22 -0
  17. shopstack/config.py +112 -0
  18. shopstack/cost_tracker.py +117 -0
  19. shopstack/data/__init__.py +0 -0
  20. shopstack/data/seed_demo.py +79 -0
  21. shopstack/decisions/__init__.py +84 -0
  22. shopstack/decisions/rules.py +733 -0
  23. shopstack/decisions/types.py +50 -0
  24. shopstack/domain/__init__.py +82 -0
  25. shopstack/market/__init__.py +35 -0
  26. shopstack/market/analytics.py +212 -0
  27. shopstack/market/basket.py +495 -0
  28. shopstack/market/combo.py +80 -0
  29. shopstack/market/comparison.py +98 -0
  30. shopstack/market/metadata.py +79 -0
  31. shopstack/market/normalization.py +447 -0
  32. shopstack/market/schema.py +93 -0
  33. shopstack/market/sources/__init__.py +79 -0
  34. shopstack/market/sources/_adapter.py +70 -0
  35. shopstack/market/sources/_blinkit_adapter.py +84 -0
  36. shopstack/market/sources/_comparison.py +121 -0
  37. shopstack/market/sources/_dmart_adapter.py +84 -0
  38. shopstack/market/sources/_json_adapter.py +218 -0
  39. shopstack/market/sources/_registry.py +117 -0
  40. shopstack/market/sources/_repository.py +202 -0
  41. shopstack/market/sources/_swiggy_adapter.py +44 -0
  42. shopstack/market/sources/_zepto_adapter.py +84 -0
  43. shopstack/market/sources/swiggy.py +279 -0
  44. shopstack/memory/waste_patterns.py +104 -0
  45. shopstack/model_registry.py +746 -0
  46. shopstack/module_registry.py +362 -0
  47. shopstack/persistence/__init__.py +0 -0
  48. shopstack/persistence/database.py +1764 -0
  49. shopstack/planner/__init__.py +0 -0
  50. shopstack/planner/engine.py +632 -0
README.md CHANGED
@@ -1,13 +1,306 @@
1
  ---
2
- title: Shopstack
3
- emoji: 🏃
4
- colorFrom: red
5
  colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: ShopStack
3
+ emoji: 🛒
4
+ colorFrom: green
5
  colorTo: blue
6
  sdk: gradio
7
+ sdk_version: "6.17.3"
 
8
  app_file: app.py
9
  pinned: false
10
+ tags: [shopstack, gradio, inventory, shopping, offline-first, household]
11
  ---
12
 
13
+ # ShopStack
14
+
15
+ Local-first, off-the-grid **shopping intelligence platform**. Know what you have, what to use soon, what to buy, what to skip, and where to buy from — without sending your data to the cloud.
16
+
17
+ ShopStack is a stack of shopping intelligence layers: home inventory (ShopStock), shopping lists and market baskets (ShopBasket), retailer price comparison (ShopCompare), scanning and import (ShopLens), price history and preferences (ShopMemory), and a reasoning agent (ShopAgent) that decides buy/skip/use-soon across all modules.
18
+
19
+ ## Philosophy
20
+
21
+ ShopStack runs entirely locally — SQLite database (WAL mode), mockable provider interfaces, and a Gradio workflow UI that works offline. The "Off the Grid" path means zero cloud dependencies for core functionality.
22
+ The default mock providers let you build and test the full app without loading any ML models.
23
+
24
+ **Total parameter limit:** ≤32 billion parameters across all loaded models.
25
+
26
+ ## Modules
27
+
28
+ | Module | Purpose |
29
+ |--------|---------|
30
+ | **ShopStock** | Inventory, pantry, fridge, expiry, low-stock, use-soon |
31
+ | **ShopBasket** | Shopping list, cart builder, market basket optimization |
32
+ | **ShopCompare** | Retailer price comparison (Swiggy, Blinkit, Zepto, ...) |
33
+ | **ShopLens** | Scanning: barcode, photo, receipt, barcode |
34
+ | **ShopMemory** | Price history, household preferences, field notes |
35
+ | **ShopAgent** | Reasoning: buy/skip/use-soon/compare decisions |
36
+ | **Sources** | Retailer datasets (Swiggy Instamart + future) |
37
+
38
+ See `Docs/SHOPSTACK_PRODUCT_ARCHITECTURE.md` for full details.
39
+
40
+ ## Gradio Workflows
41
+
42
+ ShopStack is organized around workflow experiences:
43
+
44
+ - **Today** — Decision-first dashboard: what to buy, skip, use soon, and compare
45
+ - **Ask ShopStack** — Natural language queries across all modules
46
+ - **Shopping List** — Create, classify (buy/skip/use-soon), and complete shopping plans
47
+ - **Market Lens** — Scan items via camera or voice, compare to inventory
48
+ - **Add Purchase** — Record what was bought (price, store, location)
49
+ - **Find Item at Home** — Search inventory by location and status
50
+ - **Use Soon** — Expiring and aging items flagged for attention
51
+ - **Price Memory Check** — Price history, trends, and best-store intelligence
52
+ - **Traces** — Workflow audit trail with redacted export
53
+ - **Field Notes** — Household notes and preferences
54
+
55
+ ## Quick Start
56
+
57
+ ```bash
58
+ uv venv --python 3.13
59
+ uv pip install -e ".[dev]"
60
+ uv run python app.py
61
+ ```
62
+
63
+ Open `http://localhost:7860` in your browser.
64
+
65
+ ## Market Snapshot Import
66
+
67
+ ShopStack can ingest the real Swiggy Instamart fresh vegetables snapshot found in `data/swiggy_fresh_vegetables_cards_6jun26.json` (or the matching CSV) into the local price observation database.
68
+
69
+ ```bash
70
+ uv run python scripts/import_swiggy_snapshot.py
71
+ ```
72
+
73
+ Imported observations are tagged with `source_event_id = swiggy_fresh_vegetables_20260606` so they can be filtered or audited later.
74
+
75
+ ## Tests
76
+
77
+ ```bash
78
+ uv run pytest tests/ -v
79
+ uv run pytest benchmarks/ -v -m benchmark
80
+ ```
81
+
82
+ Run `uv run pytest tests/ --collect-only -q` for the current test count.
83
+
84
+ ## Current Verified by Code Inspection
85
+
86
+ As of the current code inspection, the following metrics are verified:
87
+ - **17 Database Tables & 2 Views**: `inventory_lots`, `purchase_events`, `shopping_lists`, `shopping_list_items`, `household_locations`, `movement_events`, `price_observations`, `stores`, `traces`, `app_config`, `market_snapshots`, `market_records`, `market_record_components`, `reconciliation_events`, `preference_signals`, `inventory_events`, `households` (Tables), `price_history`, `agent_traces` (Views).
88
+ - **12 Tools**: Including `semantic_find_item`.
89
+
90
+ *Note: Tests are continuously added. For the latest test count, run `uv run pytest tests/ --collect-only -q`.*
91
+
92
+ **Engineering Mandate:** Do not narrow scope to hackathon/MVP. ShopStack is designed as a long-term, bold, and comprehensive intelligence platform. Follow `motto_v3.md` principles exactly.
93
+
94
+ ## Project Structure
95
+
96
+ ```
97
+ shopstack/
98
+ __init__.py
99
+ _version.py # v0.1.0
100
+ config.py # Settings (pydantic-settings, env prefix SHOPSTACK_)
101
+ model_registry.py # 16 candidate model entries (all ≤32B total)
102
+ schemas/
103
+ models.py # All Pydantic domain models (14+ classes, 16 enums)
104
+ providers/
105
+ interfaces.py # 11 abstract provider ABCs
106
+ mock_providers.py # Full mock implementations for all 11 (Indian/Hinglish data)
107
+ registry.py # ProviderRegistry factory wired to Settings
108
+ persistence/
109
+ database.py # SQLite Database (WAL, 17 tables, 2 views, 18 seeded locations, full CRUD)
110
+ services/ # Business logic services (decision engine, shopping, dashboard, preferences, freshness)
111
+ tools/
112
+ registry.py # ToolRegistry — 12 tools executing against Database
113
+ traces/
114
+ export.py # Trace creation, JSONL export, PII redaction
115
+ data_sources/ # Data source adapters for market snapshots and external feeds
116
+ ui/ # (reserved)
117
+ configs/ # (reserved)
118
+
119
+ app.py # Gradio Blocks UI entry point (workflow-first tabs, custom warm CSS)
120
+ tests/ # pytest test suite (run `pytest tests/ --collect-only -q` for current count)
121
+ benchmarks/ # pytest benchmark suite (9 latency markers)
122
+ ```
123
+
124
+ ## Architecture
125
+
126
+ ```
127
+ Gradio Blocks (app.py)
128
+ → ToolRegistry (12 tools, validates args, calls Database)
129
+ → Database (SQLite WAL, 17 tables, 2 views, 18 seeded locations)
130
+ → ProviderRegistry (wired from Settings)
131
+ → MockProviders (default — 11 interfaces, all offline)
132
+ → Market services (market source registry load + snapshot status helpers in `shopstack.services.market_sources`)
133
+ → Settings (pydantic-settings, env-overridable)
134
+ → ModelRegistry (16 candidates, not loaded by default)
135
+ ```
136
+
137
+ ### 11 Provider Interfaces
138
+
139
+ | Interface | Mock Behavior |
140
+ |-----------|--------------|
141
+ | `STTProvider` | Returns predefined Hindi/Hinglish phrases |
142
+ | `TTSProvider` | Writes a note about what would be spoken |
143
+ | `VisionProvider` | Randomly samples from 26 common kitchen items |
144
+ | `ObjectDetectionProvider` | Returns plausible bounding boxes + confidences |
145
+ | `GroundingProvider` | Returns grounded item references |
146
+ | `SegmentationProvider` | Returns placeholder masks |
147
+ | `OCRProvider` | Returns mock extracted text |
148
+ | `PlannerProvider` | Returns structured multi-step plans |
149
+ | `ToolCallParserProvider` | Parses intent → tool call candidates |
150
+ | `EmbeddingsProvider` | Returns random 384-d vectors |
151
+ | `ImageEditProvider` | Returns a dummy edited image path |
152
+
153
+ ### 12 Tools
154
+
155
+ | Tool | Purpose |
156
+ |------|---------|
157
+ | `add_inventory_item` | Add a new item to household inventory |
158
+ | `update_inventory_item` | Update details of an existing inventory item |
159
+ | `consume_inventory_item` | Record consumption (partial or full) |
160
+ | `move_inventory_item` | Move an item to a different storage location |
161
+ | `find_item` | Search for an item across inventory and locations |
162
+ | `semantic_find_item` | Search for an item using exact, prefix, and semantic embedding search with match quality scores |
163
+ | `create_or_update_shopping_list` | Create/update the active shopping list |
164
+ | `compare_visible_item_to_inventory` | Compare detected item against current stock |
165
+ | `record_price_observation` | Record a price observation for an item |
166
+ | `get_use_soon_items` | Get items expiring or aging soon |
167
+ | `get_next_buy_suggestions` | Get suggestions for what to buy next |
168
+ | `export_anonymized_trace` | Export an anonymized agent trace |
169
+
170
+ ### 10 Database Tables
171
+
172
+ `inventory_lots`, `purchase_events`, `shopping_lists`, `shopping_list_items`, `household_locations`, `movement_events`, `price_observations`, `stores`, `traces`, `app_config`
173
+
174
+ Compatibility aliases: `price_history` and `agent_traces` are exposed as read/delete-compatible views for older docs, tests, and scripts.
175
+
176
+ 18 hierarchical household locations seeded on every init (safe via COUNT check): Home → Kitchen → Fridge → Fridge Door → ..., Pantry → Shelf → ..., etc.
177
+
178
+ ### Trace System
179
+
180
+ Every tool execution creates an agent trace stored in the database. Traces include perception snapshots, inventory context, decision rationale, proposed tool calls, and human confirmation status. On export, traces are **redacted** for PII:
181
+
182
+ - Phone numbers (10+ digits)
183
+ - Email addresses
184
+ - Aadhar numbers (12-digit pattern)
185
+ - PAN numbers (5 letters + 4 digits + 1 letter)
186
+ - Geo addresses (street patterns)
187
+
188
+ Explicitly **not** redacted: generic `name` fields, canonical item names, location names.
189
+
190
+ ## Screens
191
+
192
+ | Tab | Purpose |
193
+ |-----|---------|
194
+ | **Plan Today's Shopping** | Dashboard workflow — today view, use-soon signals, and shopping recommendations |
195
+ | **Shopping List** | View / create / manage the active shopping list |
196
+ | **Market Lens: Should I Buy This?** | Camera / voice input → detect → compare vs inventory |
197
+ | **Add Purchase** | Manual purchase recording form with store, price, item details |
198
+ | **Find an Item at Home** | Search + map lookup for likely storage location |
199
+ | **Use Soon / Waste Saver** | Expiring and aging items with priority list |
200
+ | **Price Memory Check** | Historical price observations per item |
201
+ | **Find Item Location** | Storage hierarchy and item count view |
202
+ | **Model Stack** | Active model stack + budget status and candidate catalog |
203
+ | **Agent Trace** | Agent session trace viewer with redaction preview |
204
+ | **Field Notes** | Agent reasoning and decision log |
205
+
206
+ ## Configuration
207
+
208
+ All settings are pydantic-settings with `SHOPSTACK_` env prefix:
209
+
210
+ Operational resource guards are documented in **[`Docs/RESOURCE_OPTIMIZATION_POLICY.md`](Docs/RESOURCE_OPTIMIZATION_POLICY.md)**.
211
+
212
+ | Variable | Default | Description |
213
+ |----------|---------|-------------|
214
+ | `SHOPSTACK_DB_PATH` | `data/shopstack.db` | SQLite database file path |
215
+ | `SHOPSTACK_APP_PORT` | `7860` | Gradio server port |
216
+ | `SHOPSTACK_OFF_THE_GRID` | `true` | Use mock providers (no cloud) |
217
+ | `SHOPSTACK_LOCAL_AUTO_UNLOAD` | `true` | Unload local model runtime after each local provider call |
218
+ | `SHOPSTACK_LOCAL_WHISPER_AUTO_UNLOAD` | `true` | Unload local STT model after each transcription |
219
+ | `SHOPSTACK_TRACE_MAX_ROWS` | `2000` | Maximum number of trace rows to retain |
220
+ | `SHOPSTACK_TRACE_TTL_DAYS` | `30` | Delete traces older than this many days |
221
+ | `SHOPSTACK_STT_BACKEND` | `mock` | STT provider selection |
222
+ | `SHOPSTACK_TTS_BACKEND` | `mock` | TTS provider selection |
223
+ | `SHOPSTACK_VISION_BACKEND` | `mock` | Vision provider selection |
224
+ | `SHOPSTACK_OBJECT_DETECTION_BACKEND` | `mock` | Object detection provider |
225
+ | ... per-provider backends default to `mock` |
226
+
227
+ ## Model Catalog
228
+
229
+ See **[`Docs/MODEL_CATALOG.md`](Docs/MODEL_CATALOG.md)** for the full living model catalog — including downloaded & tested models, parameter budget tracking, runtime backends (MLX, llama.cpp/GGUF, transformers), HF Pro and Modal Labs credit resources, and experiment logs.
230
+
231
+ The programmatic registry lives in `shopstack/model_registry.py` (16+ entries across STT, TTS, Vision, OCR, Embeddings, and Planner categories).
232
+
233
+ - **Active / loaded models**: actually selected at runtime.
234
+ - **Candidate models**: documented options available for future activation.
235
+ - **Budget check**: only active/loaded models are counted against the **32B** cap (enforced by `validate_active_model_budget()`).
236
+
237
+ **Active design constraint:** Total parameter count across all simultaneously active models must not exceed 32 billion. Mock mode shows an active-loaded stack of `0B`.
238
+
239
+ ## Key Design Decisions
240
+
241
+ - **Single shared schemas file** — models are interconnected and share enums; a single file avoids circular imports.
242
+ - **Provider ABCs named `*Provider`** — `STTProvider`, not `STT`; mock classes named `Mock*Provider`.
243
+ - **PurchaseEvent enriched with per-item fields** — `canonical_name`, `quantity`, `unit`, `total_price` live on the event, not on a separate join table.
244
+ - **PriceObservation defaults** — `observation_date` defaults to `date.today()`.
245
+ - **PII redaction is targeted** — only phone, email, Aadhar, PAN, and address patterns are redacted. Generic `name` keys are preserved.
246
+ - **No auto-purchase or payment scraping** — design-level constraint. ShopStack tells you what to buy, it doesn't buy for you.
247
+
248
+ ## Development
249
+
250
+ ```bash
251
+ uv pip install -e ".[dev]"
252
+ uv run pytest tests/ -v
253
+ uv run pytest benchmarks/ -v -m benchmark
254
+ uv run python app.py
255
+ ```
256
+
257
+ ## Deployment
258
+
259
+ ShopStack can run via Docker or on any of the supported platforms.
260
+
261
+ ### Docker (local)
262
+
263
+ ```bash
264
+ docker compose up --build
265
+ # Open http://localhost:7860
266
+ ```
267
+
268
+ Data persists in a Docker volume (`shopstack_data`).
269
+
270
+ ### Docker (standalone)
271
+
272
+ ```bash
273
+ docker build -t shopstack .
274
+ docker run -p 7860:7860 -v shopstack_data:/app/data shopstack
275
+ ```
276
+
277
+ ### Railway
278
+
279
+ 1. Push your repo to GitHub.
280
+ 2. Create a new project on [Railway](https://railway.app) → **Deploy from GitHub repo**.
281
+ 3. Railway auto-detects `Dockerfile` and `railway.json`.
282
+ 4. Add a **Volume** with mount path `/app/data` (1 GB) for SQLite persistence.
283
+ 5. (Optional) Set `SHOPSTACK_HF_API_KEY` and `SHOPSTACK_PLANNER_BACKEND=huggingface` for cloud-backed planning.
284
+
285
+ ### Render
286
+
287
+ 1. Push your repo to GitHub.
288
+ 2. Create a new **Web Service** on [Render](https://render.com) → **Deploy from Dockerfile**.
289
+ 3. Select the **Starter** plan ($7/mo) — required for persistent disk.
290
+ 4. Add a **Disk** mount at `/app/data` with 1 GB.
291
+ 5. `render.yaml` is auto-detected if you connect via Blueprint.
292
+
293
+ ### Fly.io
294
+
295
+ ```bash
296
+ # Install flyctl first: https://fly.io/docs/hands-on/install-flyctl/
297
+ flyctl launch --dockerfile ./Dockerfile
298
+ flyctl volumes create shopstack_data --region <your-region> --size 1
299
+ flyctl deploy
300
+ ```
301
+
302
+ See `fly.toml` for configuration reference.
303
+
304
+ ## License
305
+
306
+ MIT
app.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ # HF Spaces: ensure DB_PATH defaults to a writable location before
6
+ # shopstack.config instantiates Settings() at module import time.
7
+ os.environ.setdefault("SHOPSTACK_DB_PATH", "shopstack.db")
8
+
9
+ import gradio as gr
10
+
11
+ from shopstack.ui.header import header_block
12
+ from shopstack.ui.theme import CSS
13
+ from shopstack.ui.tabs.context import TabContext
14
+ from shopstack.ui.tabs.today import build_today_tab, TodayTabHandles
15
+ from shopstack.ui.tabs.basket import build_basket_tab
16
+ from shopstack.ui.tabs.cookbook import build_cookbook_tab
17
+ from shopstack.ui.tabs.market import build_market_tab
18
+ from shopstack.ui.tabs.reconcile import build_reconcile_tab, ReconcileTabHandles
19
+ from shopstack.ui.tabs.memory import build_memory_tab
20
+ from shopstack.ui.household_settings import build_household_settings
21
+ from shopstack.ui.locale_save import build_locale_save
22
+ from shopstack.ui.pwa_mount import mount_pwa_static
23
+ from shopstack.services.sms_webhook import mount_sms_webhook
24
+
25
+ from shopstack.app_context import (
26
+ APP_DESCRIPTION,
27
+ APP_NAME,
28
+ current_user_id,
29
+ db,
30
+ tools,
31
+ providers,
32
+ planner,
33
+ )
34
+ from shopstack.services.i18n import load_locale_preference
35
+ from shopstack.ui.state.household import (
36
+ household_choices,
37
+ switch_household_state,
38
+ show_add_form,
39
+ hide_add_form,
40
+ create_household_state,
41
+ )
42
+ # Canonical paths — the re-exports in primitives.py emit
43
+ # DeprecationWarning on first call (see Pass 5 supersession).
44
+ from shopstack.ui.components.js_helpers import (
45
+ autocomplete_injector_js,
46
+ url_state_sync_js,
47
+ )
48
+
49
+
50
+ def build_app() -> gr.Blocks:
51
+ """Compose the ShopStack app — pure composition, no business logic.
52
+
53
+ Wires up the 6 top-level tabs, the household settings accordion,
54
+ the locale-save API endpoint, and any app-level HTTP / static
55
+ mounts. All business logic lives in the sub-modules; this
56
+ function just glues them together.
57
+
58
+ Architecture:
59
+ * gr.Blocks is the root container.
60
+ * mount_pwa_static() mounts shopstack/static/ at /static/*
61
+ so the PWA shell is reachable.
62
+ * mount_sms_webhook() mounts /api/sms/incoming for inbound
63
+ SMS / WhatsApp quick-add.
64
+ * header_block() renders the top header (brand, runtime badge,
65
+ theme toggle, i18n selector, PWA manifest link, JS).
66
+ * build_locale_save() adds the hidden i18n persistence endpoint
67
+ that the header's JS POSTs to.
68
+ * 6 build_<tab>_tab() calls render the daily product flow.
69
+ * build_household_settings() renders the workspace admin panel
70
+ (household switcher + community opt-in + SMS phone registry).
71
+ * The tail block wires cross-tab event handlers (household
72
+ switch → Today refresh, location refresh, JS injection).
73
+ """
74
+ with gr.Blocks(title=APP_NAME) as app:
75
+ # App-level bootstrap: PWA static mount + SMS webhook endpoint
76
+ mount_pwa_static(app)
77
+ mount_sms_webhook(app)
78
+
79
+ # Header: brand title, runtime badge, theme toggle, i18n selector
80
+ initial_locale = load_locale_preference(current_user_id() or "default_household")
81
+ gr.HTML(
82
+ header_block(APP_NAME, APP_DESCRIPTION, current_locale=initial_locale),
83
+ padding=True,
84
+ )
85
+
86
+ # Hidden i18n persistence endpoint (called by the header's JS)
87
+ build_locale_save()
88
+
89
+ # ── 6-tab daily loop: Home → Recipes → Groceries → While Shopping → At Home → Memory ──
90
+ with gr.Tabs(elem_classes="tabs", elem_id="main-content") as tabs:
91
+
92
+ # ═══════════════════════════════════════════════════════════════
93
+ # Tab 1: Home — what matters now?
94
+ # ═══════════════════════════════════════════════════════════════
95
+ today_handles: TodayTabHandles = build_today_tab(
96
+ blocks=app, app=app, ctx=TabContext(),
97
+ )
98
+ today_stats = today_handles.today_stats
99
+ today_soon = today_handles.today_soon
100
+ today_list = today_handles.today_list
101
+ today_low = today_handles.today_low
102
+ today_recent = today_handles.today_recent
103
+ today_changed = today_handles.today_changed
104
+
105
+ # ═══════════════════════════════════════════════════════════════
106
+ # Tab 2: Recipes — browse the 30-recipe library
107
+ # ═══════════════════════════════════════════════════════════════
108
+ build_cookbook_tab(blocks=app, app=app, ctx=TabContext())
109
+
110
+ # ═══════════════════════════════════════════════════════════════
111
+ # Tab 3: Groceries — what should I buy / skip / compare?
112
+ # ═══════════════════════════════════════════════════════════════
113
+ build_basket_tab(blocks=app, app=app, ctx=TabContext())
114
+
115
+ # ═══════════════════════════════════════════════════════════════
116
+ # Tab 4: While Shopping — check items before you buy them
117
+ # ═══════════════════════════════════════════════════════════════
118
+ build_market_tab(blocks=app, app=app, ctx=TabContext())
119
+
120
+ # ═══════════════════════════════════════════════════════════════
121
+ # Tab 5: At Home — what actually happened?
122
+ # ═══════════════════════════════════════════════════════════════
123
+ reconcile_handles = build_reconcile_tab(blocks=app, app=app, ctx=TabContext())
124
+ p_location = reconcile_handles.p_location
125
+ move_dest = reconcile_handles.move_dest
126
+
127
+ # ═══════════════════════════════════════════════════════════════
128
+ # Tab 6: Memory — what did we learn?
129
+ # ═══════════════════════════════════════════════════════════════
130
+ build_memory_tab(blocks=app, app=app, ctx=TabContext())
131
+
132
+ # Household settings accordion (workspace admin panel)
133
+ hh = build_household_settings(app)
134
+ household_dropdown = hh.household_dropdown
135
+ add_hh_btn = hh.add_hh_btn
136
+ hh_add_row = hh.hh_add_row
137
+ hh_name_input = hh.hh_name_input
138
+ hh_create_btn = hh.hh_create_btn
139
+ hh_cancel_btn = hh.hh_cancel_btn
140
+
141
+ # Refresh dropdown choices on initial load
142
+ app.load(
143
+ lambda: gr.update(choices=household_choices(), value=current_user_id()),
144
+ outputs=household_dropdown,
145
+ )
146
+
147
+ # Wire household dropdown change after all output components are defined
148
+ household_dropdown.change(
149
+ switch_household_state,
150
+ household_dropdown,
151
+ [household_dropdown, today_stats, today_soon, today_list, today_low, today_recent, today_changed],
152
+ api_name="switch_household",
153
+ api_description="Switch active household and refresh dashboard",
154
+ )
155
+
156
+ # Per-render refresh of location-dependent dropdowns.
157
+ def _refresh_location_choices() -> gr.update:
158
+ return gr.update(choices=[(l.name, l.location_id) for l in db.get_locations()])
159
+
160
+ app.load(_refresh_location_choices, outputs=p_location)
161
+ app.load(_refresh_location_choices, outputs=move_dest)
162
+
163
+ # Post-render JS: inject `autocomplete="off"` into every Gradio
164
+ # text/number input. Vercel WIG requires this on every form input.
165
+ app.load(None, js=autocomplete_injector_js())
166
+ # URL state sync: clicking a tab updates the URL hash, and
167
+ # opening the app with `#basket` deep-links to the Shopping tab.
168
+ app.load(None, js=url_state_sync_js())
169
+
170
+ # Wire add-household button and form
171
+ add_hh_btn.click(
172
+ show_add_form,
173
+ outputs=hh_add_row,
174
+ api_name="show_add_household",
175
+ api_description="Show the add-household form",
176
+ )
177
+ hh_cancel_btn.click(
178
+ hide_add_form,
179
+ outputs=hh_add_row,
180
+ api_name="cancel_add_household",
181
+ api_description="Hide the add-household form without creating",
182
+ )
183
+ hh_create_btn.click(
184
+ create_household_state,
185
+ hh_name_input,
186
+ [household_dropdown, hh_add_row, today_stats, today_soon, today_list, today_low, today_recent, today_changed],
187
+ api_name="create_household",
188
+ api_description="Create a new household, switch to it, and refresh the dashboard",
189
+ )
190
+
191
+ return app
192
+
193
+
194
+
195
+ if __name__ == "__main__":
196
+ import argparse
197
+
198
+ parser = argparse.ArgumentParser()
199
+ parser.add_argument("--port", type=int, default=7860)
200
+ parser.add_argument("--share", action="store_true")
201
+ args = parser.parse_args()
202
+
203
+ app = build_app()
204
+ app.launch(server_port=args.port, share=args.share, theme=gr.themes.Base(), css=CSS)
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ libzbar0
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=5.0,<7.0
2
+ pydantic>=2.0,<3.0
3
+ pydantic-settings>=2.0,<3.0
4
+ huggingface_hub>=0.20
5
+ httpx>=0.25
6
+ pillow>=10.0
7
+ pandas>=2.0
8
+ pydub>=0.25
9
+ pytest>=8.0,<9.0
10
+ pytest-cov>=5.0,<6.0
setup.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from setuptools import find_packages, setup
2
+
3
+ setup(
4
+ name="shopstack",
5
+ version="0.1.0",
6
+ packages=find_packages(),
7
+ include_package_data=True,
8
+ python_requires=">=3.10",
9
+ install_requires=[
10
+ "gradio>=5",
11
+ "pydantic>=2",
12
+ "pydantic-settings>=2",
13
+ "huggingface_hub>=0.20",
14
+ "httpx>=0.25",
15
+ "pydub",
16
+ "pillow",
17
+ "pandas",
18
+ ],
19
+ extras_require={
20
+ "dev": [
21
+ "pytest>=9",
22
+ "pytest-cov",
23
+ "pytest-benchmark",
24
+ ],
25
+ "cloud": [
26
+ "openai>=1.0",
27
+ ],
28
+ "local": [
29
+ "llama-cpp-python>=0.3",
30
+ ],
31
+ "otel": [
32
+ "opentelemetry-api>=1.30",
33
+ "opentelemetry-sdk>=1.30",
34
+ "opentelemetry-exporter-otlp-proto-grpc>=1.30",
35
+ ],
36
+ "eval": [
37
+ "ruff>=0.9",
38
+ ],
39
+ },
40
+ )
shopstack/__init__.py ADDED
File without changes
shopstack/_legacy_decisions.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DB-aware render wrappers for decision dashboard cards.
2
+
3
+ These functions combine data fetching (from Database) with HTML rendering
4
+ (from ui.renderers.decision_cards). They exist here because the decisions
5
+ package cannot import from UI without creating circular imports.
6
+
7
+ New code should call renderers directly with pre-fetched data rather than
8
+ using these wrappers.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ from datetime import date
15
+ from typing import Any
16
+
17
+ from shopstack.persistence.database import Database
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # --- Render wrappers (backward-compatible with old db-based signatures) ---
23
+
24
+
25
+ def render_market_basket(ds) -> str:
26
+ from shopstack.ui.renderers.decision_cards import render_market_basket
27
+ return render_market_basket(ds)
28
+
29
+
30
+ def render_inventory_overview(all_inv: list[Any]) -> str:
31
+ from shopstack.ui.renderers.decision_cards import render_inventory_overview
32
+ return render_inventory_overview(all_inv)
33
+
34
+
35
+ def render_my_list_panel(ds, active_list: Any) -> str:
36
+ from shopstack.ui.renderers.decision_cards import render_my_list_panel
37
+ return render_my_list_panel(ds, active_list)
38
+
39
+
40
+ def render_compare_panel(ds) -> str:
41
+ from shopstack.ui.renderers.decision_cards import render_compare_panel
42
+ return render_compare_panel(ds)
43
+
44
+
45
+ def render_decision_panel(ds) -> str:
46
+ from shopstack.ui.renderers.decision_cards import render_decision_panel
47
+ return render_decision_panel(ds)
48
+
49
+
50
+ def render_what_changed(db: Database) -> str:
51
+ from shopstack.ui.renderers.decision_cards import render_what_changed
52
+ purchases = db.get_purchase_events(limit=5)
53
+ traces = db.get_traces(limit=5)
54
+ return render_what_changed(purchases, traces)
55
+
56
+
57
+ def render_cadence_insights(db: Database) -> str:
58
+ from shopstack.decisions import detect_purchase_cadence
59
+ from shopstack.ui.renderers.decision_cards import render_cadence_insights
60
+ cadence = detect_purchase_cadence(db)
61
+ return render_cadence_insights(cadence)
62
+
63
+
64
+ def render_waste_warnings(db: Database) -> str:
65
+ from shopstack.decisions import detect_waste_patterns
66
+ from shopstack.ui.renderers.decision_cards import render_waste_warnings
67
+ signals = detect_waste_patterns(db)
68
+ return render_waste_warnings(signals)
69
+
70
+
71
+ def render_swiggy_soldout_warning(shopping_list_names: list[str]) -> str:
72
+ from shopstack.decisions import check_swiggy_availability
73
+ from shopstack.ui.renderers.decision_cards import render_swiggy_soldout_warning
74
+ avail = check_swiggy_availability(shopping_list_names)
75
+ return render_swiggy_soldout_warning(avail)
76
+
77
+
78
+ def render_needs_confirmation(db: Database) -> str:
79
+ from shopstack.ui.renderers.decision_cards import render_needs_confirmation
80
+ all_inv = db.get_inventory()
81
+ uncertain = [
82
+ lot for lot in all_inv
83
+ if lot.status == "active" and lot.quantity > 0 and (
84
+ not lot.purchase_date
85
+ or (date.today() - lot.purchase_date).days > 14
86
+ )
87
+ ]
88
+ return render_needs_confirmation(uncertain)
shopstack/_version.py ADDED
@@ -0,0 +1 @@
 
 
1
+ __version__ = "0.1.0"
shopstack/app_context.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from shopstack.config import settings
4
+ from shopstack.model_registry import get_registry
5
+ from shopstack.module_registry import (
6
+ ModuleMetadata,
7
+ get_all as get_all_modules,
8
+ navigation as _build_navigation,
9
+ summary_table as _build_summary,
10
+ )
11
+ from shopstack.persistence.database import Database
12
+ from shopstack.planner.engine import PlannerEngine
13
+ from shopstack.providers.registry import ProviderRegistry
14
+ from shopstack.tools.registry import ToolRegistry
15
+
16
+ # ── Shared product constants (single source of truth) ──────────────
17
+ # These derive from config but are exported here so every UI surface
18
+ # imports from one place instead of hardcoding copy.
19
+ APP_NAME: str = settings.app_name
20
+ APP_DESCRIPTION: str = settings.app_description
21
+
22
+ # ── Module registry (module metadata + lookup helpers) ──────────────
23
+ # See shopstack/module_registry.py for all module definitions.
24
+ MODULES: list[ModuleMetadata] = get_all_modules()
25
+ MODULE_SUMMARY: list[dict[str, str]] = _build_summary()
26
+ NAV_ENTRIES: list[tuple[str, str, str]] = _build_navigation()
27
+
28
+ # ── Core singletons ─────────────────────────────────────────────────
29
+ db = Database(settings.db_path)
30
+
31
+ # Resolve active household from stored config, so every DB operation
32
+ # is scoped to the current household automatically.
33
+ _household_id = db.active_household_id
34
+
35
+ providers = ProviderRegistry(settings)
36
+ # Wire embeddings provider into ToolRegistry for semantic search fallback.
37
+ # The embedding provider is lazy-resolved from ProviderRegistry; if BGE-M3
38
+ # or sentence-transformers is unavailable, semantic_find_item falls back to
39
+ # prefix search automatically.
40
+ tools = ToolRegistry(db, embedding_provider=providers.embeddings)
41
+ planner = PlannerEngine(db, tools, providers)
42
+ model_registry = get_registry()
43
+
44
+
45
+ # ── Service singletons (wired from app_context) ────────────────────
46
+ from shopstack.services.trace import TraceService # noqa: E402 — circular import
47
+
48
+ _trace_service: TraceService | None = None
49
+
50
+
51
+ def get_trace_service() -> TraceService:
52
+ global _trace_service
53
+ if _trace_service is None:
54
+ _trace_service = TraceService(db)
55
+ return _trace_service
56
+
57
+
58
+ def current_user_id() -> str:
59
+ """Return the currently active household/user ID for DB scoping.
60
+
61
+ Screen builders should call this and pass the result as ``user_id``
62
+ to every ``db.*()`` call that accepts the parameter. This ensures
63
+ all inventory, shopping list, and trace queries are scoped to the
64
+ active household.
65
+ """
66
+ return db.active_household_id
67
+
68
+
69
+ def switch_household(household_id: str) -> bool:
70
+ """Switch the active household. Returns True if successful."""
71
+ if not household_id:
72
+ return False
73
+ # Verify the household exists
74
+ households = db.list_households()
75
+ if not any(h["household_id"] == household_id for h in households):
76
+ return False
77
+ db.active_household_id = household_id
78
+ return True
79
+
80
+
81
+ def list_households() -> list[dict[str, str]]:
82
+ """List all registered households."""
83
+ return db.list_households()
84
+
85
+
86
+ def add_household(household_id: str, name: str) -> bool:
87
+ """Register a new household."""
88
+ return db.add_household(household_id, name)
89
+
90
+
91
+ def runtime_label() -> str:
92
+ """Return a human-readable label describing the current provider runtime.
93
+
94
+ Uses the provider registry to determine whether real AI backends are loaded
95
+ or the app is running in mock mode. Safe to call at import time.
96
+ """
97
+ try:
98
+ runtime = providers.get_runtime_diagnostics()
99
+ loaded_real = [
100
+ r for r in runtime.providers
101
+ if getattr(r, "loaded", False) and getattr(r, "backend", "") != "mock"
102
+ ]
103
+ return "Local runtime" if loaded_real else "Local mock mode"
104
+ except Exception:
105
+ return "Local runtime"
shopstack/basket/__init__.py ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ from shopstack.basket.models import BasketCandidate, BasketItem
2
+ from shopstack.basket.service import optimize_baskets
3
+
4
+ __all__ = ["BasketCandidate", "BasketItem", "optimize_baskets"]
shopstack/basket/constraints.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+ from typing import Any
3
+ from shopstack.basket.models import BasketCandidate
4
+
5
+ def apply_constraints(candidate: BasketCandidate, rules: dict[str, Any]) -> BasketCandidate:
6
+ """
7
+ Apply hard constraints to a candidate basket.
8
+ Rules could contain:
9
+ - max_cost: float
10
+ - require_fresh: bool
11
+ - avoid_ads: bool
12
+ """
13
+
14
+ # Example logic for constraints
15
+ if rules.get("max_cost") and candidate.total_cost > rules["max_cost"]:
16
+ candidate.overall_score -= 1000 # heavy penalty
17
+
18
+ if rules.get("require_fresh"):
19
+ for item in candidate.items:
20
+ if item.freshness == "stale":
21
+ candidate.overall_score -= 50
22
+
23
+ if rules.get("avoid_ads"):
24
+ for item in candidate.items:
25
+ if item.is_ad:
26
+ candidate.overall_score -= 20
27
+
28
+ return candidate
shopstack/basket/models.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+ from pydantic import BaseModel, Field
5
+
6
+ class BasketItem(BaseModel):
7
+ canonical_name: str
8
+ display_name: str
9
+ source: str
10
+ quantity: float
11
+ price_inr: float
12
+ price_per_kg: float | None = None
13
+ freshness: str = "unknown"
14
+ waste_risk: str = "unknown"
15
+ is_ad: bool = False
16
+ is_upgrade: bool = False
17
+ price_status: str = "known"
18
+ notes: str | None = None
19
+
20
+ class BasketCandidate(BaseModel):
21
+ id: str = ""
22
+ items: list[BasketItem] = Field(default_factory=list)
23
+ source_name: str # "swiggy", "zepto", "dmart", or "mixed"
24
+ total_cost: float = 0.0
25
+
26
+ # Components of the rank
27
+ usefulness_score: float = 0.0
28
+ cost_score: float = 0.0
29
+ freshness_score: float = 0.0
30
+ waste_risk_score: float = 0.0
31
+ preference_score: float = 0.0
32
+
33
+ overall_score: float = 0.0
34
+ missing_items: list[str] = Field(default_factory=list)
35
+
36
+ @property
37
+ def item_count(self) -> int:
38
+ return len(self.items)
shopstack/basket/scoring.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from shopstack.basket.models import BasketCandidate
4
+
5
+ def calculate_scores(candidate: BasketCandidate) -> BasketCandidate:
6
+ # Usefulness: based on proportion of found items vs missing items
7
+ total_wanted = candidate.item_count + len(candidate.missing_items)
8
+ if total_wanted > 0:
9
+ candidate.usefulness_score = (candidate.item_count / total_wanted) * 100
10
+ else:
11
+ candidate.usefulness_score = 0.0
12
+
13
+ # Cost score: Inverse of cost (higher is better). Just a relative heuristic.
14
+ if candidate.total_cost > 0:
15
+ candidate.cost_score = 10000.0 / candidate.total_cost # Arbitrary scaling
16
+ else:
17
+ candidate.cost_score = 0.0
18
+
19
+ # Freshness score: penalty for stale items
20
+ stale_count = sum(1 for i in candidate.items if i.freshness == "stale")
21
+ candidate.freshness_score = max(0, 100 - (stale_count * 20))
22
+
23
+ # Waste risk score: penalty for high waste items
24
+ high_waste = sum(1 for i in candidate.items if i.waste_risk == "high")
25
+ candidate.waste_risk_score = max(0, 100 - (high_waste * 15))
26
+
27
+ # Preference score: penalize sponsored ads
28
+ ads = sum(1 for i in candidate.items if i.is_ad)
29
+ candidate.preference_score = max(0, 100 - (ads * 10))
30
+
31
+ # Overall score: weighted sum
32
+ candidate.overall_score = (
33
+ candidate.usefulness_score * 0.4 +
34
+ candidate.cost_score * 0.3 +
35
+ candidate.freshness_score * 0.1 +
36
+ candidate.waste_risk_score * 0.1 +
37
+ candidate.preference_score * 0.1
38
+ )
39
+ return candidate
shopstack/basket/service.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ from shopstack.basket.models import BasketCandidate, BasketItem
6
+ from shopstack.basket.scoring import calculate_scores
7
+ from shopstack.schemas.models import DecisionSet
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ def _prefer_freshness(
13
+ source_id: str,
14
+ snapshot,
15
+ source_registry,
16
+ ) -> str:
17
+ """Resolve freshness from source registry metadata when available."""
18
+ if source_registry is not None:
19
+ try:
20
+ status = source_registry.freshness_of(source_id)
21
+ if isinstance(status, dict) and "is_stale" in status:
22
+ return "stale" if bool(status.get("is_stale")) else "fresh"
23
+ except Exception as exc: # pragma: no cover - defensive compatibility
24
+ logger.debug("Freshness lookup failed for %s: %s", source_id, exc)
25
+
26
+ if getattr(snapshot, "is_stale", False):
27
+ return "stale"
28
+ return "fresh"
29
+
30
+
31
+ def _build_decision_only_basket(decision_set: DecisionSet) -> list[BasketCandidate]:
32
+ """Build a useful fallback basket without any live snapshot data."""
33
+ if not decision_set.buy:
34
+ return []
35
+
36
+ candidate = BasketCandidate(source_name="decision_only", id="basket_decision_only")
37
+ for decision in decision_set.buy:
38
+ candidate.items.append(
39
+ BasketItem(
40
+ canonical_name=decision.canonical_name,
41
+ display_name=decision.display_name,
42
+ source="decision_only",
43
+ quantity=float(getattr(decision, "requested_quantity", 1.0)),
44
+ price_inr=0.0,
45
+ price_status="unavailable",
46
+ notes="No active market snapshot loaded — price will be local list estimate.",
47
+ freshness="unknown",
48
+ waste_risk=getattr(decision, "waste_risk", "unknown"),
49
+ is_ad=False,
50
+ is_upgrade=False,
51
+ )
52
+ )
53
+ candidate.missing_items = []
54
+ calculate_scores(candidate)
55
+ return [candidate]
56
+
57
+
58
+ def _load_fallback_snapshot(source_registry) -> dict[str, object]:
59
+ """Try a deterministic single-source fallback for demo stability."""
60
+ snapshots: dict[str, object] = {}
61
+ if source_registry is None:
62
+ return snapshots
63
+
64
+ try:
65
+ registered = list(source_registry.registered())
66
+ except Exception:
67
+ registered = []
68
+
69
+ for source_id in ("swiggy", "blinkit", "zepto", "dmart"):
70
+ if source_id not in registered:
71
+ continue
72
+ try:
73
+ snapshot = source_registry.latest(source_id)
74
+ if snapshot is None:
75
+ snapshot = source_registry.load(source_id)
76
+ if snapshot is not None and getattr(snapshot, "normalized_records", None):
77
+ snapshots[source_id] = snapshot
78
+ break
79
+ except Exception as exc: # pragma: no cover - data adapter failures
80
+ logger.debug("Fallback snapshot load failed for %s: %s", source_id, exc)
81
+ continue
82
+
83
+ return snapshots
84
+
85
+
86
+ def optimize_baskets(
87
+ decision_set: DecisionSet,
88
+ source_registry=None,
89
+ ) -> list[BasketCandidate]:
90
+ """Generate and rank basket candidates based on buy decisions."""
91
+ buy_items = decision_set.buy
92
+ if not buy_items:
93
+ return []
94
+
95
+ # Map canonical names to market records across all sources
96
+ all_snapshots: dict[str, object] = {}
97
+ if source_registry:
98
+ try:
99
+ all_snapshots = source_registry.all_snapshots()
100
+ except Exception:
101
+ all_snapshots = {}
102
+
103
+ if not all_snapshots:
104
+ all_snapshots = _load_fallback_snapshot(source_registry)
105
+ if not all_snapshots:
106
+ # Deterministic UX: never return empty for non-empty shopping lists.
107
+ return _build_decision_only_basket(decision_set)
108
+
109
+ # If source snapshots exist but contain no market rows, still avoid hard-empty.
110
+ if not any(snapshot.normalized_records for snapshot in all_snapshots.values()):
111
+ return _build_decision_only_basket(decision_set)
112
+
113
+ candidates: list[BasketCandidate] = []
114
+
115
+ # 1. Generate a single-source basket for each source
116
+ for source_id, snapshot in all_snapshots.items():
117
+ if not snapshot or not snapshot.normalized_records:
118
+ continue
119
+
120
+ candidate = BasketCandidate(source_name=source_id, id=f"basket_{source_id}")
121
+ source_records = {r.canonical_name: r for r in snapshot.normalized_records if r.is_available}
122
+ source_freshness = _prefer_freshness(source_id, snapshot, source_registry)
123
+
124
+ total_cost = 0.0
125
+ missing = []
126
+
127
+ for decision in buy_items:
128
+ cname = decision.canonical_name
129
+ record = source_records.get(cname)
130
+ if record:
131
+ item = BasketItem(
132
+ canonical_name=cname,
133
+ display_name=decision.display_name,
134
+ source=source_id,
135
+ quantity=1.0, # default
136
+ price_inr=record.price_inr,
137
+ price_per_kg=record.price_per_kg,
138
+ freshness=source_freshness,
139
+ waste_risk=decision.waste_risk,
140
+ is_ad=getattr(record, "is_ad", False),
141
+ is_upgrade=getattr(record, "is_upgrade", False),
142
+ notes=record.raw_name if record.is_ad or record.is_upgrade else None,
143
+ )
144
+ candidate.items.append(item)
145
+ total_cost += item.price_inr
146
+ else:
147
+ missing.append(cname)
148
+
149
+ candidate.total_cost = total_cost
150
+ candidate.missing_items = missing
151
+ if candidate.items or candidate.missing_items:
152
+ calculate_scores(candidate)
153
+ candidates.append(candidate)
154
+
155
+ # 2. Generate a mixed (cheapest overall) basket
156
+ mixed_candidate = BasketCandidate(source_name="mixed", id="basket_mixed")
157
+ mixed_cost = 0.0
158
+ mixed_missing = []
159
+
160
+ for decision in buy_items:
161
+ cname = decision.canonical_name
162
+ best_record = None
163
+ best_source = None
164
+
165
+ for source_id, snapshot in all_snapshots.items():
166
+ if not snapshot or not snapshot.normalized_records:
167
+ continue
168
+ for r in snapshot.normalized_records:
169
+ if r.canonical_name == cname and r.is_available:
170
+ if best_record is None or r.price_inr < best_record.price_inr:
171
+ best_record = r
172
+ best_source = source_id
173
+
174
+ if best_record and best_source:
175
+ snapshot = all_snapshots[best_source]
176
+ source_freshness = _prefer_freshness(best_source, snapshot, source_registry)
177
+ mixed_candidate.items.append(
178
+ BasketItem(
179
+ canonical_name=cname,
180
+ display_name=decision.display_name,
181
+ source=best_source,
182
+ quantity=1.0,
183
+ price_inr=best_record.price_inr,
184
+ price_per_kg=best_record.price_per_kg,
185
+ freshness=source_freshness,
186
+ waste_risk=decision.waste_risk,
187
+ is_ad=getattr(best_record, "is_ad", False),
188
+ is_upgrade=getattr(best_record, "is_upgrade", False),
189
+ notes=best_record.raw_name if best_record.is_ad or best_record.is_upgrade else None,
190
+ )
191
+ )
192
+ mixed_cost += best_record.price_inr
193
+ else:
194
+ mixed_missing.append(cname)
195
+
196
+ mixed_candidate.total_cost = mixed_cost
197
+ mixed_candidate.missing_items = mixed_missing
198
+ if mixed_candidate.items or mixed_candidate.missing_items:
199
+ calculate_scores(mixed_candidate)
200
+ candidates.append(mixed_candidate)
201
+
202
+ # Sort by overall score descending
203
+ candidates.sort(key=lambda c: c.overall_score, reverse=True)
204
+
205
+ return candidates
shopstack/catalog/models.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass, field
2
+ from enum import Enum
3
+ from typing import List, Optional
4
+
5
+ class WasteRisk(str, Enum):
6
+ LOW = "low"
7
+ MEDIUM = "medium"
8
+ HIGH = "high"
9
+
10
+ class ProductCategory(str, Enum):
11
+ VEGETABLES = "vegetables"
12
+ FRUITS = "fruits"
13
+ DAIRY = "dairy"
14
+ MEAT = "meat"
15
+ GROCERY = "grocery"
16
+ HERBS_AND_SPICES = "herbs_and_spices"
17
+ BEVERAGES = "beverages"
18
+ SNACKS = "snacks"
19
+ OTHER = "other"
20
+
21
+ @dataclass
22
+ class ProductCatalogEntry:
23
+ canonical_id: str
24
+ canonical_name: str
25
+ aliases: List[str] = field(default_factory=list)
26
+ category: ProductCategory = ProductCategory.OTHER
27
+ shelf_life_days: Optional[int] = None
28
+ waste_risk: WasteRisk = WasteRisk.MEDIUM
29
+ storage_hints: str = ""
30
+ unit_preference: str = "pieces"
31
+ variant_family: str = ""
32
+ premium_claims: List[str] = field(default_factory=list)
33
+ substitute_groups: List[str] = field(default_factory=list)
shopstack/catalog/service.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List, Optional
2
+ from shopstack.catalog.models import ProductCatalogEntry
3
+
4
+ class CatalogService:
5
+ def __init__(self):
6
+ self._catalog: Dict[str, ProductCatalogEntry] = {}
7
+ self._alias_map: Dict[str, str] = {}
8
+
9
+ def add_entry(self, entry: ProductCatalogEntry):
10
+ self._catalog[entry.canonical_id] = entry
11
+ self._alias_map[entry.canonical_name.lower()] = entry.canonical_id
12
+ for alias in entry.aliases:
13
+ self._alias_map[alias.lower()] = entry.canonical_id
14
+
15
+ def get_by_canonical_id(self, canonical_id: str) -> Optional[ProductCatalogEntry]:
16
+ return self._catalog.get(canonical_id)
17
+
18
+ def get_by_name(self, name: str) -> Optional[ProductCatalogEntry]:
19
+ canonical_id = self._alias_map.get(name.lower().strip())
20
+ if canonical_id:
21
+ return self._catalog.get(canonical_id)
22
+ return None
shopstack/config.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from pydantic_settings import BaseSettings
6
+
7
+
8
+ class Settings(BaseSettings):
9
+ app_name: str = "ShopStack"
10
+ app_description: str = "Shopping intelligence platform: know what you have, what to buy, what to skip, and where to buy from."
11
+ app_version: str = "0.1.0"
12
+ debug: bool = True
13
+
14
+ ui_mode: str = "consumer" # "consumer" | "developer" — gates developer-facing UI elements
15
+
16
+ off_the_grid: bool = True
17
+ default_household_user_id: str = "default_household"
18
+ app_port: int = 7860
19
+ db_path: str = str(Path(__file__).resolve().parent.parent / "data" / "shopstack.db")
20
+ data_dir: str = str(Path(__file__).resolve().parent.parent / "data")
21
+
22
+ openai_api_key: str = ""
23
+ hf_api_key: str = ""
24
+
25
+ local_model_dir: str = ""
26
+ local_model_repo: str = "unsloth/Llama-3.2-3B-Instruct-GGUF"
27
+ local_model_file: str = "Llama-3.2-3B-Instruct-Q4_K_M.gguf"
28
+ local_mlx_model: str = "mlx-community/Ministral-8B-Instruct-2410-4bit"
29
+ local_auto_download: bool = True
30
+
31
+ local_whisper_size: str = "tiny"
32
+ local_auto_unload: bool = True
33
+ local_whisper_auto_unload: bool = True
34
+ model_stack: str = "default"
35
+
36
+ trace_max_rows: int = 2000
37
+ trace_ttl_days: int = 30
38
+
39
+ cost_budget_limit: float = 1.00
40
+
41
+ planner_backend: str = "local"
42
+ stt_backend: str = "sensevoice"
43
+ tts_backend: str = "kokoro"
44
+ vision_backend: str = "qwen3vl"
45
+ ocr_backend: str = "tesseract"
46
+ segmentation_backend: str = "birefnet"
47
+ grounding_backend: str = "grounding_dino"
48
+ image_gen_backend: str = "svg"
49
+ embeddings_backend: str = "nomic"
50
+ tool_call_parser_backend: str = "minicpm5"
51
+ planner_compact_tools: bool = False # Use compact type-shorthand tool descriptions (~90% accuracy vs ~50%)
52
+ planner_allow_writes: bool = False
53
+
54
+ model_config = {"env_file": ".env", "env_prefix": "SHOPSTACK_", "extra": "ignore"}
55
+
56
+ def __init__(self, **values):
57
+ super().__init__(**values)
58
+ # Backward-compatibility shim for the old environment variable name.
59
+ # Prefer new SHOPSTACK_DB_PATH when explicitly set.
60
+ legacy_db_path = os.getenv("SHOPSTACK_DATABASE_PATH")
61
+ if legacy_db_path and "db_path" not in self.model_dump(exclude_unset=True):
62
+ self.db_path = legacy_db_path
63
+
64
+ self._apply_model_stack_preset()
65
+
66
+ def _apply_model_stack_preset(self) -> None:
67
+ """Overlay a named model stack preset onto unset provider backends."""
68
+ preset = self.model_stack.strip().lower()
69
+ if preset != "openbmb_local":
70
+ return
71
+
72
+ explicit_fields = set(self.model_dump(exclude_unset=True).keys())
73
+ preset_backends = {
74
+ "planner_backend": "minicpm5",
75
+ "ocr_backend": "glm_ocr", # overrides default tesseract for vision-native OCR
76
+ }
77
+ # vision_backend=qwen3vl, segmentation_backend=birefnet, embeddings_backend=nomic,
78
+ # and most other backends are now the default — no longer need explicit preset overrides.
79
+ for field_name, backend in preset_backends.items():
80
+ if field_name not in explicit_fields:
81
+ setattr(self, field_name, backend)
82
+
83
+ @property
84
+ def provider_backends(self) -> dict[str, str]:
85
+ """Backward-compatible provider backend map.
86
+
87
+ Canonical configuration is in *_backend fields (for example
88
+ stt_backend), but existing callers may still read provider_backends.
89
+ """
90
+ backends = {
91
+ "stt": self.stt_backend,
92
+ "tts": self.tts_backend,
93
+ "vision": self.vision_backend,
94
+ "object_detection": self.vision_backend,
95
+ "grounding": self.grounding_backend,
96
+ "segmentation": self.segmentation_backend,
97
+ "ocr": self.ocr_backend,
98
+ "planner": self.planner_backend,
99
+ "tool_call_parser": self.tool_call_parser_backend,
100
+ "embeddings": self.embeddings_backend,
101
+ "image_edit": self.image_gen_backend,
102
+ "image_gen": self.image_gen_backend,
103
+ }
104
+ return backends
105
+
106
+ @property
107
+ def database_path(self) -> str:
108
+ """Backward-compatible alias for legacy callers."""
109
+ return self.db_path
110
+
111
+
112
+ settings = Settings()
shopstack/cost_tracker.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+ MODEL_PRICING: dict[str, dict[str, float | str]] = {
7
+ "gpt-4o": {"input_per_mtok": 2.50, "output_per_mtok": 10.00},
8
+ "gpt-4o-mini": {"input_per_mtok": 0.15, "output_per_mtok": 0.60},
9
+ "text-embedding-3-small": {"input_per_mtok": 0.02, "output_per_mtok": 0.02},
10
+ "whisper-1": {"input_per_mtok": 0.0, "output_per_mtok": 0.0, "note": "priced per audio minute"},
11
+ "microsoft/Phi-3-mini-4k-instruct": {"input_per_mtok": 0.0, "output_per_mtok": 0.0, "note": "HF inference may bill separately"},
12
+ "mlx-community/Llama-3.2-3B-Instruct-4bit": {"input_per_mtok": 0.0, "output_per_mtok": 0.0, "note": "local - free"},
13
+ "unsloth/Llama-3.2-3B-Instruct-GGUF": {"input_per_mtok": 0.0, "output_per_mtok": 0.0, "note": "local - free"},
14
+ "mock": {"input_per_mtok": 0.0, "output_per_mtok": 0.0, "note": "mock - free"},
15
+ }
16
+
17
+ INPUT_TIER_THRESHOLDS: list[tuple[int, str]] = [
18
+ (300, "haiku"),
19
+ (1000, "sonnet"),
20
+ ]
21
+
22
+ MODEL_TIER_MAP: dict[str, str] = {
23
+ "gpt-4o-mini": "haiku",
24
+ "gpt-4o": "sonnet",
25
+ "microsoft/Phi-3-mini-4k-instruct": "haiku",
26
+ "mlx-community/Llama-3.2-3B-Instruct-4bit": "local",
27
+ "unsloth/Llama-3.2-3B-Instruct-GGUF": "local",
28
+ "mock": "mock",
29
+ }
30
+
31
+ TIER_COST_MULTIPLIER: dict[str, float] = {
32
+ "haiku": 1.0,
33
+ "sonnet": 4.0,
34
+ "local": 0.0,
35
+ "mock": 0.0,
36
+ }
37
+
38
+
39
+ def estimate_model_tier(text_length: int, item_count: int = 0) -> str:
40
+ for threshold, tier in INPUT_TIER_THRESHOLDS:
41
+ if text_length < threshold and item_count < 30:
42
+ return tier
43
+ return "sonnet"
44
+
45
+
46
+ def estimate_cost_usd(
47
+ model_key: str,
48
+ input_tokens: int,
49
+ output_tokens: int,
50
+ ) -> float:
51
+ pricing = MODEL_PRICING.get(model_key)
52
+ if not pricing:
53
+ return 0.0
54
+ input_rate = pricing.get("input_per_mtok", 0)
55
+ output_rate = pricing.get("output_per_mtok", 0)
56
+ input_cost = (input_tokens / 1_000_000) * (input_rate if isinstance(input_rate, (int, float)) else 0.0)
57
+ output_cost = (output_tokens / 1_000_000) * (output_rate if isinstance(output_rate, (int, float)) else 0.0)
58
+ return round(input_cost + output_cost, 6)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class CostRecord:
63
+ model: str
64
+ input_tokens: int
65
+ output_tokens: int
66
+ cost_usd: float
67
+ tier: str
68
+ latency_ms: float | None = None
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class CostTracker:
73
+ budget_limit: float = 1.00
74
+ records: tuple[CostRecord, ...] = ()
75
+
76
+ def add(self, record: CostRecord) -> CostTracker:
77
+ return CostTracker(
78
+ budget_limit=self.budget_limit,
79
+ records=(*self.records, record),
80
+ )
81
+
82
+ @property
83
+ def total_cost(self) -> float:
84
+ return sum(r.cost_usd for r in self.records)
85
+
86
+ @property
87
+ def total_input_tokens(self) -> int:
88
+ return sum(r.input_tokens for r in self.records)
89
+
90
+ @property
91
+ def total_output_tokens(self) -> int:
92
+ return sum(r.output_tokens for r in self.records)
93
+
94
+ @property
95
+ def over_budget(self) -> bool:
96
+ return self.total_cost > self.budget_limit
97
+
98
+ def summary(self) -> dict[str, Any]:
99
+ return {
100
+ "total_cost": self.total_cost,
101
+ "total_input_tokens": self.total_input_tokens,
102
+ "total_output_tokens": self.total_output_tokens,
103
+ "call_count": len(self.records),
104
+ "budget_limit": self.budget_limit,
105
+ "over_budget": self.over_budget,
106
+ "records": [
107
+ {
108
+ "model": r.model,
109
+ "input_tokens": r.input_tokens,
110
+ "output_tokens": r.output_tokens,
111
+ "cost_usd": r.cost_usd,
112
+ "tier": r.tier,
113
+ "latency_ms": r.latency_ms,
114
+ }
115
+ for r in self.records
116
+ ],
117
+ }
shopstack/data/__init__.py ADDED
File without changes
shopstack/data/seed_demo.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seed demo inventory data for developer walkthroughs and testing.
2
+
3
+ This module lives in shopstack.data so the screen module (inventory.py)
4
+ doesn't carry a large static data array in its module body.
5
+ """
6
+
7
+ DEMO_SEED_INVENTORY = [
8
+ {
9
+ "canonical_name": "milk",
10
+ "display_name": "Milk",
11
+ "quantity": 2.0,
12
+ "unit": "liter",
13
+ "price": 74.0,
14
+ "store": "local milk booth",
15
+ "location": "fridge",
16
+ "category": "dairy",
17
+ "expiry": 7,
18
+ },
19
+ {
20
+ "canonical_name": "rice",
21
+ "display_name": "Basmati Rice",
22
+ "quantity": 5.0,
23
+ "unit": "kg",
24
+ "price": 410.0,
25
+ "store": "Big Bazaar",
26
+ "location": "pantry",
27
+ "category": "grains",
28
+ },
29
+ {
30
+ "canonical_name": "eggs",
31
+ "display_name": "Eggs",
32
+ "quantity": 12.0,
33
+ "unit": "pieces",
34
+ "price": 96.0,
35
+ "store": "Morning Eggstop",
36
+ "location": "fridge_top",
37
+ "category": "protein",
38
+ },
39
+ {
40
+ "canonical_name": "onion",
41
+ "display_name": "Onion",
42
+ "quantity": 1.5,
43
+ "unit": "kg",
44
+ "price": 32.0,
45
+ "store": "Local Vendor",
46
+ "location": "pantry_mid",
47
+ "category": "vegetable",
48
+ },
49
+ {
50
+ "canonical_name": "toothpaste",
51
+ "display_name": "Toothpaste",
52
+ "quantity": 1.0,
53
+ "unit": "unit",
54
+ "price": 129.0,
55
+ "store": "Apna Store",
56
+ "location": "bathroom_cabinet",
57
+ "category": "personal care",
58
+ },
59
+ {
60
+ "canonical_name": "olive oil",
61
+ "display_name": "Olive Oil",
62
+ "quantity": 1.0,
63
+ "unit": "L",
64
+ "price": 690.0,
65
+ "store": "Supermart",
66
+ "location": "pantry_mid",
67
+ "category": "cooking",
68
+ },
69
+ {
70
+ "canonical_name": "curd",
71
+ "display_name": "Curd",
72
+ "quantity": 0.5,
73
+ "unit": "kg",
74
+ "price": 48.0,
75
+ "store": "Fresh Dairy",
76
+ "location": "fridge",
77
+ "category": "dairy",
78
+ },
79
+ ]
shopstack/decisions/__init__.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from shopstack.decisions.types import (
2
+ Decision,
3
+ DECISION_COLORS,
4
+ DECISION_ICONS,
5
+ ACTION_MAP,
6
+ )
7
+ from shopstack.schemas.models import (
8
+ DecisionEvidence,
9
+ DecisionResult,
10
+ DecisionSet,
11
+ DecisionWarning,
12
+ FreshnessStatus,
13
+ )
14
+ from shopstack.decisions.rules import (
15
+ classify_all,
16
+ _classify,
17
+ _get_produce_meta,
18
+ detect_purchase_cadence,
19
+ detect_waste_patterns,
20
+ check_swiggy_availability,
21
+ )
22
+
23
+ _RENDER_NAMES = {
24
+ "render_market_basket",
25
+ "render_inventory_overview",
26
+ "render_my_list_panel",
27
+ "render_compare_panel",
28
+ "render_decision_panel",
29
+ }
30
+ _LEGACY_NAMES = {
31
+ "render_what_changed",
32
+ "render_cadence_insights",
33
+ "render_waste_warnings",
34
+ "render_swiggy_soldout_warning",
35
+ "render_needs_confirmation",
36
+ }
37
+
38
+
39
+ def __getattr__(name: str):
40
+ """Lazy re-exports for backward-compatible render wrappers.
41
+
42
+ New code should import directly from shopstack.ui.renderers.decision_cards
43
+ or shopstack._legacy_decisions.
44
+
45
+ This __getattr__ avoids a circular import: decisions → renderers → decisions
46
+ that would otherwise crash when any code path triggers both packages.
47
+ """
48
+ if name in _RENDER_NAMES:
49
+ import shopstack.ui.renderers as _r
50
+ return getattr(_r, name)
51
+ if name in _LEGACY_NAMES:
52
+ import shopstack._legacy_decisions as _l
53
+ return getattr(_l, name)
54
+ msg = f"module {__name__!r} has no attribute {name!r}"
55
+ raise AttributeError(msg)
56
+
57
+
58
+ __all__ = [
59
+ "Decision",
60
+ "DECISION_COLORS",
61
+ "DECISION_ICONS",
62
+ "ACTION_MAP",
63
+ "DecisionEvidence",
64
+ "DecisionResult",
65
+ "DecisionSet",
66
+ "DecisionWarning",
67
+ "FreshnessStatus",
68
+ "classify_all",
69
+ "_classify",
70
+ "_get_produce_meta",
71
+ "detect_purchase_cadence",
72
+ "detect_waste_patterns",
73
+ "check_swiggy_availability",
74
+ "render_market_basket",
75
+ "render_inventory_overview",
76
+ "render_my_list_panel",
77
+ "render_compare_panel",
78
+ "render_decision_panel",
79
+ "render_what_changed",
80
+ "render_cadence_insights",
81
+ "render_waste_warnings",
82
+ "render_swiggy_soldout_warning",
83
+ "render_needs_confirmation",
84
+ ]
shopstack/decisions/rules.py ADDED
@@ -0,0 +1,733 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decision rules — classification logic for household shopping decisions.
2
+
3
+ This module computes buy / skip / use-soon / optional / compare / wait
4
+ decisions from inventory state, shopping list, and market signals. Pure logic —
5
+ no HTML rendering, no database access except through passed-in interfaces.
6
+
7
+ Every decision produces a DecisionResult (from shopstack.schemas.models).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from datetime import date, timedelta
14
+ from typing import Any
15
+
16
+ from shopstack.schemas.models import (
17
+ DecisionEvidence,
18
+ DecisionResult,
19
+ DecisionSet,
20
+ DecisionWarning,
21
+ )
22
+ from shopstack.decisions.types import Decision, ACTION_MAP
23
+ from shopstack.persistence.database import Database
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ _LOW_STOCK_THRESHOLD = 0.5
28
+ _USE_SOON_DAYS = 3
29
+ _RECENT_PURCHASE_DAYS = 2
30
+
31
+
32
+ def _get_use_soon(inventory: Any, days: int, user_id: str = "") -> dict[str, Any]:
33
+ """Call get_use_soon (InventoryRepo) or get_use_soon_items (ToolRegistry)."""
34
+ if hasattr(inventory, "get_use_soon"):
35
+ return inventory.get_use_soon(days=days, user_id=user_id)
36
+ return inventory.get_use_soon_items(days=days, user_id=user_id)
37
+
38
+
39
+ def classify_all(
40
+ db: Database,
41
+ inventory: Any,
42
+ market_snapshot=None,
43
+ source_registry: Any = None,
44
+ user_id: str = "",
45
+ ) -> DecisionSet:
46
+ from shopstack.services.preference import PreferenceService
47
+ pref_service = PreferenceService(db)
48
+ uid = user_id
49
+ staples = set(pref_service.get_staples(user_id=uid))
50
+ disliked = set(pref_service.get_disliked(user_id=uid))
51
+
52
+ active_inv = [lot for lot in db.get_inventory(user_id=uid) if lot.status == "active"]
53
+ use_soon_items = _get_use_soon(inventory, _USE_SOON_DAYS, user_id=uid).get("items", [])
54
+ active_list = db.get_active_shopping_list(user_id=uid)
55
+ purchases = db.get_purchase_events(limit=50, user_id=uid)
56
+ recent_dates: set[date] = set()
57
+ for p in purchases:
58
+ try:
59
+ pdate = p.timestamp.date() if hasattr(p.timestamp, "date") else p.timestamp
60
+ if pdate and pdate >= date.today() - timedelta(days=_RECENT_PURCHASE_DAYS):
61
+ recent_dates.add(pdate)
62
+ except Exception:
63
+ pass
64
+
65
+ use_soon_names = {item.get("canonical_name", "") for item in use_soon_items}
66
+ list_names: set[str] = set()
67
+ if active_list and active_list.items:
68
+ list_names = {item.canonical_name for item in active_list.items if item.status in ("pending", "seen")}
69
+
70
+ market_by_canonical: dict[str, Any] = {}
71
+ market_evidence_map: dict[str, Any] = {}
72
+
73
+ # Build market data from single snapshot or multi-source registry
74
+ _multi_source_market_data(market_snapshot, source_registry, market_by_canonical, market_evidence_map)
75
+
76
+ # Purchase cadence — used for predictive restock recommendations
77
+ cadence_data = detect_purchase_cadence(db, user_id=uid)
78
+
79
+ seen: set[str] = set()
80
+ decisions: list[DecisionResult] = []
81
+
82
+ # Import decision engine services locally to avoid circular imports
83
+ from shopstack.services.freshness import FreshnessReport
84
+ from shopstack.services.decision_engine import should_buy, should_skip, use_soon
85
+
86
+ for lot in active_inv:
87
+ cname = lot.canonical_name
88
+ if cname in seen:
89
+ continue
90
+ seen.add(cname)
91
+
92
+ meta = _get_produce_meta(cname)
93
+ market = market_by_canonical.get(cname)
94
+
95
+ use_soon_match = cname in use_soon_names
96
+ low_stock = lot.quantity <= _LOW_STOCK_THRESHOLD or lot.status == "low"
97
+ on_list = cname in list_names
98
+ recently_bought = lot.purchase_date and lot.purchase_date in recent_dates
99
+ is_staple = cname in staples
100
+ is_disliked = cname in disliked
101
+
102
+ # Derive freshness report for the market record
103
+ fr = None
104
+ if market:
105
+ evidence_val = market_evidence_map.get(cname, {})
106
+ cap_at = evidence_val.get("captured_at")
107
+ if cap_at:
108
+ if not isinstance(cap_at, str):
109
+ cap_at = getattr(cap_at, "isoformat", lambda: str(cap_at))()
110
+ fr = FreshnessReport(
111
+ status="stale" if evidence_val.get("is_stale") else "fresh",
112
+ age_days=evidence_val.get("age_days"),
113
+ label="",
114
+ captured_at=cap_at,
115
+ is_stale=bool(evidence_val.get("is_stale")),
116
+ warning="",
117
+ )
118
+
119
+ decision_res = None
120
+
121
+ if use_soon_match:
122
+ decision_res = use_soon(
123
+ canonical_name=cname,
124
+ display_name=lot.display_name,
125
+ quantity_at_home=lot.quantity,
126
+ unit=lot.unit,
127
+ shelf_life_days=meta.shelf_life_days if meta else 0,
128
+ purchase_date=lot.purchase_date,
129
+ waste_risk=meta.waste_risk if meta else "unknown",
130
+ )
131
+ if decision_res:
132
+ decision_res.source_trace = "rule:classify_all:inventory_loop"
133
+
134
+ if not decision_res:
135
+ decision_res = should_buy(
136
+ canonical_name=cname,
137
+ display_name=lot.display_name,
138
+ quantity_at_home=lot.quantity,
139
+ unit=lot.unit,
140
+ market_record=market,
141
+ freshness=fr,
142
+ on_shopping_list=on_list,
143
+ is_staple=is_staple,
144
+ waste_risk=meta.waste_risk if meta else "unknown",
145
+ purchase_cadence_days=cadence_data.get(cname, {}).get("avg_interval_days"),
146
+ last_purchase_date=lot.purchase_date,
147
+ recently_bought=bool(recently_bought),
148
+ is_disliked=is_disliked,
149
+ )
150
+ if decision_res:
151
+ if is_staple and not any("staple" in r.lower() for r in decision_res.reasons):
152
+ decision_res.reasons.append("Household staple")
153
+ decision_res.confidence = min(decision_res.confidence + 0.1, 0.95)
154
+ decision_res.source_trace = "rule:classify_all:inventory_loop"
155
+
156
+ if not decision_res:
157
+ decision_res = should_skip(
158
+ canonical_name=cname,
159
+ display_name=lot.display_name,
160
+ quantity_at_home=lot.quantity,
161
+ unit=lot.unit,
162
+ waste_risk=meta.waste_risk if meta else "unknown",
163
+ on_shopping_list=on_list,
164
+ recently_bought=bool(recently_bought),
165
+ market_record=market,
166
+ freshness=fr,
167
+ is_disliked=is_disliked,
168
+ )
169
+ if decision_res:
170
+ decision_res.source_trace = "rule:classify_all:inventory_loop"
171
+
172
+ if not decision_res:
173
+ data_freshness, data_freshness_label = _freshness_for(cname, market_evidence_map)
174
+ decision_res = DecisionResult(
175
+ canonical_name=cname,
176
+ display_name=lot.display_name,
177
+ action="watch",
178
+ confidence=0.5,
179
+ reasons=["Monitor"],
180
+ evidence=[DecisionEvidence(source="inventory", value=f"{lot.quantity} {lot.unit}", confidence=1.0)],
181
+ source_trace="rule:classify_all:inventory_loop",
182
+ quantity_at_home=lot.quantity,
183
+ unit=lot.unit,
184
+ market_price=market.price_inr if market else None,
185
+ market_price_per_kg=market.price_per_kg if market else None,
186
+ market_available=bool(market),
187
+ market_raw_size=market.raw_size if market else "",
188
+ shopping_list_status="on_list" if on_list else "",
189
+ waste_risk=meta.waste_risk if meta else "unknown",
190
+ shelf_life_days=meta.shelf_life_days if meta else 0,
191
+ last_purchase_date=lot.purchase_date,
192
+ location=lot.storage_location_id or "",
193
+ data_freshness=data_freshness,
194
+ data_freshness_label=data_freshness_label,
195
+ )
196
+
197
+ # Enrich fields to be fully backwards-compatible
198
+ decision_res.shopping_list_status = "on_list" if on_list else ""
199
+ decision_res.shelf_life_days = meta.shelf_life_days if meta else 0
200
+ decision_res.last_purchase_date = lot.purchase_date
201
+ decision_res.location = lot.storage_location_id or ""
202
+
203
+ if is_disliked and not any(w.code == "disliked_item" for w in decision_res.warnings):
204
+ decision_res.warnings.append(DecisionWarning(code="disliked_item", message="Disliked/avoided by household", severity="warning"))
205
+
206
+ if meta and meta.waste_risk == "high" and decision_res.action == "buy" and not any(w.code == "waste_risk" for w in decision_res.warnings):
207
+ decision_res.warnings.append(DecisionWarning(code="waste_risk", message="High waste risk for this item", severity="warning"))
208
+
209
+ if market and getattr(market, 'is_stale', False) and not any(w.code == "stale_data" for w in decision_res.warnings):
210
+ decision_res.warnings.append(DecisionWarning(code="stale_data", message="Market data is stale", severity="warning"))
211
+
212
+ decisions.append(decision_res)
213
+
214
+ if active_list and active_list.items:
215
+ for item in active_list.items:
216
+ if item.status not in ("pending", "seen"):
217
+ continue
218
+ if item.canonical_name in seen:
219
+ continue
220
+ seen.add(item.canonical_name)
221
+
222
+ inv_match = next((lot for lot in active_inv if lot.canonical_name == item.canonical_name), None)
223
+ meta = _get_produce_meta(item.canonical_name)
224
+ market = market_by_canonical.get(item.canonical_name)
225
+ qty = inv_match.quantity if inv_match else 0
226
+ low_stock = qty <= _LOW_STOCK_THRESHOLD
227
+ use_soon_match = item.canonical_name in use_soon_names
228
+ is_staple = item.canonical_name in staples
229
+ is_disliked = item.canonical_name in disliked
230
+
231
+ fr = None
232
+ if market:
233
+ evidence_val = market_evidence_map.get(item.canonical_name, {})
234
+ cap_at = evidence_val.get("captured_at")
235
+ if cap_at:
236
+ if not isinstance(cap_at, str):
237
+ cap_at = getattr(cap_at, "isoformat", lambda: str(cap_at))()
238
+ fr = FreshnessReport(
239
+ status="stale" if evidence_val.get("is_stale") else "fresh",
240
+ age_days=evidence_val.get("age_days"),
241
+ label="",
242
+ captured_at=cap_at,
243
+ is_stale=bool(evidence_val.get("is_stale")),
244
+ warning="",
245
+ )
246
+
247
+ decision_res = None
248
+ if is_disliked:
249
+ decision_res = should_skip(
250
+ canonical_name=item.canonical_name,
251
+ display_name=item.canonical_name.replace("_", " ").title(),
252
+ quantity_at_home=qty,
253
+ unit=inv_match.unit if inv_match else "unit",
254
+ waste_risk=meta.waste_risk if meta else "unknown",
255
+ on_shopping_list=True,
256
+ recently_bought=False,
257
+ market_record=market,
258
+ freshness=fr,
259
+ is_disliked=True,
260
+ )
261
+ if decision_res:
262
+ decision_res.source_trace = "rule:classify_all:list_loop"
263
+
264
+ elif inv_match is None:
265
+ decision_res = should_buy(
266
+ canonical_name=item.canonical_name,
267
+ display_name=item.canonical_name.replace("_", " ").title(),
268
+ quantity_at_home=0.0,
269
+ unit="unit",
270
+ market_record=market,
271
+ freshness=fr,
272
+ on_shopping_list=True,
273
+ is_staple=is_staple,
274
+ waste_risk=meta.waste_risk if meta else "unknown",
275
+ is_disliked=is_disliked,
276
+ )
277
+ if decision_res:
278
+ if is_staple and not any("staple" in r.lower() for r in decision_res.reasons):
279
+ decision_res.reasons.append("Household staple")
280
+ decision_res.confidence = min(decision_res.confidence + 0.1, 0.95)
281
+ decision_res.source_trace = "rule:classify_all:list_loop"
282
+ else:
283
+ if use_soon_match:
284
+ decision_res = use_soon(
285
+ canonical_name=item.canonical_name,
286
+ display_name=inv_match.display_name,
287
+ quantity_at_home=inv_match.quantity,
288
+ unit=inv_match.unit,
289
+ shelf_life_days=meta.shelf_life_days if meta else 0,
290
+ purchase_date=inv_match.purchase_date,
291
+ waste_risk=meta.waste_risk if meta else "unknown",
292
+ )
293
+ if decision_res:
294
+ decision_res.source_trace = "rule:classify_all:list_loop"
295
+
296
+ if not decision_res:
297
+ decision_res = should_buy(
298
+ canonical_name=item.canonical_name,
299
+ display_name=inv_match.display_name,
300
+ quantity_at_home=inv_match.quantity,
301
+ unit=inv_match.unit,
302
+ market_record=market,
303
+ freshness=fr,
304
+ on_shopping_list=True,
305
+ is_staple=is_staple,
306
+ waste_risk=meta.waste_risk if meta else "unknown",
307
+ is_disliked=is_disliked,
308
+ )
309
+ if decision_res:
310
+ if is_staple and not any("staple" in r.lower() for r in decision_res.reasons):
311
+ decision_res.reasons.append("Household staple")
312
+ decision_res.confidence = min(decision_res.confidence + 0.1, 0.95)
313
+ decision_res.source_trace = "rule:classify_all:list_loop"
314
+
315
+ if not decision_res:
316
+ decision_res = should_skip(
317
+ canonical_name=item.canonical_name,
318
+ display_name=inv_match.display_name,
319
+ quantity_at_home=inv_match.quantity,
320
+ unit=inv_match.unit,
321
+ waste_risk=meta.waste_risk if meta else "unknown",
322
+ on_shopping_list=True,
323
+ recently_bought=False,
324
+ market_record=market,
325
+ freshness=fr,
326
+ is_disliked=is_disliked,
327
+ )
328
+ if decision_res:
329
+ decision_res.source_trace = "rule:classify_all:list_loop"
330
+
331
+ if not decision_res:
332
+ data_freshness, data_freshness_label = _freshness_for(item.canonical_name, market_evidence_map)
333
+ decision_res = DecisionResult(
334
+ canonical_name=item.canonical_name,
335
+ display_name=item.canonical_name.replace("_", " ").title(),
336
+ action="watch",
337
+ confidence=0.5,
338
+ reasons=["Monitor"],
339
+ evidence=[DecisionEvidence(source="shopping_list", value="on_list", confidence=1.0)],
340
+ source_trace="rule:classify_all:list_loop",
341
+ quantity_at_home=qty,
342
+ unit=inv_match.unit if inv_match else "unit",
343
+ market_price=market.price_inr if market else None,
344
+ market_price_per_kg=market.price_per_kg if market else None,
345
+ market_available=bool(market),
346
+ market_raw_size=market.raw_size if market else "",
347
+ shopping_list_status="on_list",
348
+ waste_risk=meta.waste_risk if meta else "unknown",
349
+ shelf_life_days=meta.shelf_life_days if meta else 0,
350
+ last_purchase_date=inv_match.purchase_date if inv_match else None,
351
+ location=inv_match.storage_location_id if inv_match else "",
352
+ data_freshness=data_freshness,
353
+ data_freshness_label=data_freshness_label,
354
+ )
355
+
356
+ decision_res.shopping_list_status = "on_list"
357
+ decision_res.shelf_life_days = meta.shelf_life_days if meta else 0
358
+ decision_res.last_purchase_date = inv_match.purchase_date if inv_match else None
359
+ decision_res.location = inv_match.storage_location_id if inv_match else ""
360
+
361
+ if is_disliked and not any(w.code == "disliked_item" for w in decision_res.warnings):
362
+ decision_res.warnings.append(DecisionWarning(code="disliked_item", message="Disliked/avoided by household", severity="warning"))
363
+
364
+ decisions.append(decision_res)
365
+
366
+ if market_snapshot is not None:
367
+ market_source_name = getattr(market_snapshot, 'source', 'market')
368
+ for cname, r in market_by_canonical.items():
369
+ if cname in seen:
370
+ continue
371
+ seen.add(cname)
372
+
373
+ meta = _get_produce_meta(cname)
374
+ price_ppk = r.price_per_kg or 0
375
+ if price_ppk <= 0:
376
+ continue
377
+
378
+ all_weighted = [
379
+ rec for rec in market_snapshot.normalized_records
380
+ if rec.canonical_name == cname and rec.is_weight_based and not rec.is_combo
381
+ ]
382
+ if len(all_weighted) >= 2:
383
+ prices = [rec.price_per_kg for rec in all_weighted if rec.price_per_kg]
384
+ if prices and price_ppk <= min(prices) * 1.05:
385
+ action = "optional"
386
+ reason_str = f"Good price: \u20b9{price_ppk:.0f}/kg on {market_source_name}"
387
+ confidence = 0.7
388
+ else:
389
+ action = "watch"
390
+ reason_str = f"Available at \u20b9{price_ppk:.0f}/kg on {market_source_name}"
391
+ confidence = 0.5
392
+ else:
393
+ action = "watch"
394
+ reason_str = f"Available at \u20b9{price_ppk:.0f}/kg on {market_source_name}"
395
+ confidence = 0.5
396
+
397
+ data_freshness, data_freshness_label = _freshness_for(cname, market_evidence_map)
398
+ decisions.append(DecisionResult(
399
+ canonical_name=cname,
400
+ display_name=cname.replace("_", " ").title(),
401
+ action=action,
402
+ confidence=confidence,
403
+ reasons=[reason_str],
404
+ evidence=[DecisionEvidence(source="market", value=f"\u20b9{r.price_inr} at {market_source_name}", confidence=1.0)],
405
+ source_trace="rule:classify_all:market_loop",
406
+ quantity_at_home=0,
407
+ unit="",
408
+ market_price=r.price_inr,
409
+ market_price_per_kg=r.price_per_kg,
410
+ market_available=True,
411
+ market_raw_size=r.raw_size,
412
+ waste_risk=meta.waste_risk if meta else "unknown",
413
+ shelf_life_days=meta.shelf_life_days if meta else 0,
414
+ data_freshness=data_freshness,
415
+ data_freshness_label=data_freshness_label,
416
+ ))
417
+
418
+ # Populate snapshot-level metadata from market evidence
419
+ snap_source = ""
420
+ snap_captured = ""
421
+ snap_freshness = "unknown"
422
+ if market_evidence_map:
423
+ first_ev = next(iter(market_evidence_map.values()))
424
+ snap_source = first_ev.get("source", "")
425
+ captured = first_ev.get("captured_at")
426
+ if captured:
427
+ try:
428
+ from datetime import datetime as _dt
429
+ if isinstance(captured, str):
430
+ captured = _dt.fromisoformat(captured)
431
+ snap_captured = captured.isoformat()
432
+ except Exception:
433
+ snap_captured = str(captured)
434
+ any_stale = any(ev.get("is_stale", False) for ev in market_evidence_map.values())
435
+ snap_freshness = "stale" if any_stale else "fresh"
436
+
437
+ return DecisionSet(
438
+ decisions=decisions,
439
+ snapshot_source=snap_source,
440
+ snapshot_captured_at=snap_captured,
441
+ snapshot_freshness=snap_freshness,
442
+ )
443
+
444
+
445
+ def _classify(
446
+ quantity: float,
447
+ unit: str,
448
+ low_stock: bool,
449
+ use_soon: bool,
450
+ on_list: bool,
451
+ recently_bought: bool,
452
+ has_market: bool,
453
+ waste_risk: str,
454
+ is_disliked: bool = False,
455
+ ) -> tuple[str, str, float]:
456
+
457
+ if is_disliked:
458
+ return Decision.SKIP.value, "Disliked/avoided by household", 0.95
459
+
460
+ if use_soon and quantity > 0:
461
+ if low_stock:
462
+ return Decision.USE_SOON.value, "Use remaining before it expires, then restock", 0.85
463
+ return Decision.USE_SOON.value, "Use existing before buying more", 0.9
464
+
465
+ if low_stock and quantity <= 0:
466
+ if has_market:
467
+ return Decision.BUY.value, "Out of stock, available on Swiggy", 0.9
468
+ return Decision.BUY.value, "Out of stock", 0.85
469
+
470
+ if low_stock:
471
+ if has_market:
472
+ return Decision.BUY.value, f"Running low ({quantity} {unit} left)", 0.85
473
+ return Decision.BUY.value, f"Running low ({quantity} {unit} left)", 0.8
474
+
475
+ if on_list and quantity > 0:
476
+ if waste_risk == "high":
477
+ return Decision.SKIP.value, "Already have enough, high waste risk if you buy more", 0.8
478
+ return Decision.SKIP.value, "Already have enough at home", 0.75
479
+
480
+ if quantity > 0 and not low_stock and not use_soon:
481
+ if recently_bought:
482
+ return Decision.SKIP.value, "Recently purchased", 0.8
483
+ if waste_risk == "high":
484
+ return Decision.SKIP.value, "Stocked, high waste risk", 0.7
485
+ return Decision.SKIP.value, "Well stocked", 0.7
486
+
487
+ return Decision.WATCH.value, "Monitor", 0.5
488
+
489
+
490
+ def _get_produce_meta(canonical_name: str):
491
+ try:
492
+ from shopstack.market.metadata import get_produce_metadata
493
+ return get_produce_metadata(canonical_name)
494
+ except Exception:
495
+ return None
496
+
497
+
498
+ def _freshness_for(cname: str, evidence_map: dict[str, Any]) -> tuple[str, str]:
499
+ """Derive (data_freshness, data_freshness_label) from market evidence."""
500
+ ev = evidence_map.get(cname)
501
+ if not ev:
502
+ return "unknown", ""
503
+ age = ev.get("age_days", 0)
504
+ is_stale = ev.get("is_stale", False)
505
+ freshness = "stale" if is_stale else "fresh"
506
+ captured = ev.get("captured_at")
507
+ if captured:
508
+ try:
509
+ from datetime import datetime
510
+ if isinstance(captured, str):
511
+ captured = datetime.fromisoformat(captured)
512
+ label = f"Snapshot from {captured.strftime('%-d %b %Y')}"
513
+ except Exception:
514
+ label = ""
515
+ else:
516
+ label = ""
517
+ return freshness, label
518
+
519
+
520
+ def classify_inventory_comparison(
521
+ total_have: float, requested_qty: float, unit: str, is_use_soon: bool
522
+ ) -> tuple[str, str]:
523
+ """Return (decision, reason) from raw inventory comparison facts.
524
+
525
+ Thresholds:
526
+ >= 2x requested → skip (already plenty)
527
+ >= 1x requested → optional (have enough, buy only if needed)
528
+ < 1x requested → buy (need more)
529
+ """
530
+ if total_have <= 0:
531
+ return "buy", f"Not found in inventory."
532
+
533
+ shortfall = max(requested_qty - total_have, 0)
534
+ surplus_ratio = total_have / requested_qty if requested_qty > 0 else float("inf")
535
+
536
+ if surplus_ratio >= 2:
537
+ return "skip", f"Already have {total_have} {unit} at home."
538
+ if surplus_ratio >= 1:
539
+ return "optional", f"Have {total_have} {unit}. Only buy if needed."
540
+ return "buy", f"Have only {total_have} {unit}. Buy {shortfall} {unit}."
541
+
542
+
543
+ def _multi_source_market_data(
544
+ market_snapshot,
545
+ source_registry,
546
+ market_by_canonical: dict,
547
+ market_evidence_map: dict,
548
+ ) -> None:
549
+ """Populate market_by_canonical and market_evidence_map from single snapshot or multi-source registry.
550
+
551
+ Logs warnings on partial failures instead of silently degrading.
552
+ """
553
+ if source_registry is not None:
554
+ try:
555
+ all_snapshots = source_registry.all_snapshots()
556
+ for source_id, snap in all_snapshots.items():
557
+ if snap and snap.normalized_records:
558
+ for r in snap.normalized_records:
559
+ if r.is_available and r.is_weight_based and not r.is_combo:
560
+ existing = market_by_canonical.get(r.canonical_name)
561
+ if existing is None or (r.price_per_kg and existing.price_per_kg and r.price_per_kg < existing.price_per_kg):
562
+ market_by_canonical[r.canonical_name] = r
563
+
564
+ for source_id, snap in all_snapshots.items():
565
+ if not snap or not snap.normalized_records:
566
+ continue
567
+ for cname in set(market_by_canonical.keys()):
568
+ records = [r for r in snap.normalized_records if r.canonical_name == cname]
569
+ if not records:
570
+ continue
571
+ best = market_by_canonical.get(cname)
572
+ existing_evidence = market_evidence_map.get(cname)
573
+ price_per_kg = best.price_per_kg if best else None
574
+ if existing_evidence:
575
+ if price_per_kg is not None and existing_evidence.get("best_value_per_kg") is not None:
576
+ if price_per_kg < existing_evidence["best_value_per_kg"]:
577
+ existing_evidence["source"] = source_id
578
+ existing_evidence["best_value_price"] = best.price_inr if best else None
579
+ existing_evidence["best_value_per_kg"] = price_per_kg
580
+ else:
581
+ freshness = _build_freshness(snap)
582
+ market_evidence_map[cname] = {
583
+ "source": source_id,
584
+ "captured_at": snap.captured_at,
585
+ "age_days": freshness.get("age_days", 0),
586
+ "is_stale": freshness.get("is_stale", True),
587
+ "best_value_price": best.price_inr if best else None,
588
+ "best_value_per_kg": price_per_kg,
589
+ }
590
+ except Exception:
591
+ logger.warning("Multi-source market data failed, falling back to single snapshot", exc_info=True)
592
+
593
+ # Fallback: single snapshot path
594
+ if market_snapshot is not None and not market_by_canonical:
595
+ for r in market_snapshot.normalized_records:
596
+ if r.is_available and r.is_weight_based and not r.is_combo:
597
+ existing = market_by_canonical.get(r.canonical_name)
598
+ if existing is None or (r.price_per_kg and existing.price_per_kg and r.price_per_kg < existing.price_per_kg):
599
+ market_by_canonical[r.canonical_name] = r
600
+ for cname in set(market_by_canonical.keys()):
601
+ records = [r for r in market_snapshot.normalized_records if r.canonical_name == cname]
602
+ available = [r for r in records if r.is_available]
603
+ sold_out = [r for r in records if not r.is_available]
604
+ best = market_by_canonical.get(cname)
605
+ freshness = _build_freshness(market_snapshot)
606
+ market_evidence_map[cname] = {
607
+ "source": market_snapshot.source,
608
+ "captured_at": market_snapshot.captured_at,
609
+ "age_days": freshness.get("age_days", 0),
610
+ "is_stale": freshness.get("is_stale", True),
611
+ "best_value_price": best.price_inr if best else None,
612
+ "best_value_per_kg": best.price_per_kg if best else None,
613
+ }
614
+
615
+
616
+ def _build_freshness(market_snapshot) -> dict[str, Any]:
617
+ try:
618
+ from shopstack.market.sources.swiggy import snapshot_freshness
619
+ return snapshot_freshness(market_snapshot)
620
+ except Exception:
621
+ logger.debug("Market snapshot freshness check unavailable", exc_info=True)
622
+ return {"age_days": 0, "is_stale": False, "label": "unknown"}
623
+
624
+
625
+ def detect_purchase_cadence(db: Database, user_id: str = "") -> dict[str, dict[str, Any]]:
626
+ purchases = db.get_purchase_events(limit=200, user_id=user_id)
627
+ by_item: dict[str, list[Any]] = {}
628
+ for p in purchases:
629
+ try:
630
+ dt = p.timestamp if hasattr(p.timestamp, "year") else None
631
+ except Exception:
632
+ dt = None
633
+ if dt is None:
634
+ continue
635
+ by_item.setdefault(p.canonical_name, []).append(p)
636
+
637
+ cadence: dict[str, dict[str, Any]] = {}
638
+ for cname, events in by_item.items():
639
+ events.sort(key=lambda e: e.timestamp, reverse=True)
640
+ if len(events) < 2:
641
+ continue
642
+ intervals = []
643
+ for i in range(len(events) - 1):
644
+ d1 = events[i].timestamp.date() if hasattr(events[i].timestamp, "date") else events[i].timestamp
645
+ d2 = events[i + 1].timestamp.date() if hasattr(events[i + 1].timestamp, "date") else events[i + 1].timestamp
646
+ if d1 and d2:
647
+ gap = (d1 - d2).days
648
+ if gap > 0:
649
+ intervals.append(gap)
650
+ if not intervals:
651
+ continue
652
+ avg_interval = sum(intervals) / len(intervals)
653
+ last = events[0].timestamp.date() if hasattr(events[0].timestamp, "date") else events[0].timestamp
654
+ next_expected = last + timedelta(days=round(avg_interval))
655
+ typical_qty = sum(e.quantity for e in events) / len(events)
656
+ cadence[cname] = {
657
+ "avg_interval_days": round(avg_interval, 1),
658
+ "last_bought": last,
659
+ "typical_qty": round(typical_qty, 2),
660
+ "typical_unit": events[0].unit,
661
+ "next_expected": next_expected,
662
+ "purchase_count": len(events),
663
+ }
664
+ return cadence
665
+
666
+
667
+ def detect_waste_patterns(db: Database, user_id: str = "") -> list[dict[str, Any]]:
668
+ cadence = detect_purchase_cadence(db, user_id=user_id)
669
+ waste_signals: list[dict[str, Any]] = []
670
+ inv = db.get_inventory(user_id=user_id)
671
+
672
+ for cname, info in cadence.items():
673
+ if info["purchase_count"] < 3:
674
+ continue
675
+ meta = _get_produce_meta(cname)
676
+ waste_risk = meta.waste_risk if meta else "unknown"
677
+ if waste_risk != "high":
678
+ continue
679
+
680
+ lot = next((lot for lot in inv if lot.canonical_name == cname and lot.status == "active"), None)
681
+ overstocked = lot and lot.quantity > 1.0
682
+ if overstocked or info["avg_interval_days"] < 2:
683
+ waste_signals.append({
684
+ "canonical_name": cname,
685
+ "display_name": cname.replace("_", " ").title(),
686
+ "reason": f"High waste-risk produce bought every {info['avg_interval_days']:.0f} days",
687
+ "current_quantity": lot.quantity if lot else 0,
688
+ "unit": lot.unit if lot else "unit",
689
+ "waste_risk": waste_risk,
690
+ "avg_interval_days": info["avg_interval_days"],
691
+ })
692
+
693
+ return waste_signals
694
+
695
+
696
+ def check_swiggy_availability(canonical_names: list[str]) -> dict[str, dict[str, Any]]:
697
+ try:
698
+ from shopstack.market.sources.swiggy import load_snapshot
699
+ snap = load_snapshot()
700
+ except Exception:
701
+ return {}
702
+
703
+ result: dict[str, dict[str, Any]] = {}
704
+ all_records: dict[str, list[Any]] = {}
705
+ for r in snap.normalized_records:
706
+ all_records.setdefault(r.canonical_name, []).append(r)
707
+
708
+ for cname in canonical_names:
709
+ records = all_records.get(cname, [])
710
+ if not records:
711
+ continue
712
+ available = [r for r in records if r.is_available]
713
+ sold_out = [r for r in records if not r.is_available]
714
+ if available:
715
+ best = min(
716
+ (r for r in available if r.is_weight_based and not r.is_combo and r.price_per_kg),
717
+ key=lambda r: r.price_per_kg,
718
+ default=available[0],
719
+ )
720
+ result[cname] = {
721
+ "available": True,
722
+ "price": best.price_inr,
723
+ "price_per_kg": best.price_per_kg,
724
+ "raw_size": best.raw_size,
725
+ }
726
+ elif sold_out:
727
+ result[cname] = {
728
+ "available": False,
729
+ "price": sold_out[0].price_inr,
730
+ "price_per_kg": sold_out[0].price_per_kg,
731
+ "raw_size": sold_out[0].raw_size,
732
+ }
733
+ return result
shopstack/decisions/types.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Decision types — pure data structures for the ShopStack decision engine.
2
+
3
+ No HTML rendering, no database access, no provider calls.
4
+ Every ShopStack decision (buy / skip / use-soon / compare / etc.) uses
5
+ DecisionResult as its canonical representation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date, datetime
11
+ from enum import Enum
12
+ from typing import Any
13
+
14
+ from shopstack.schemas.models import ( # noqa: F401 — canonical types
15
+ DecisionEvidence,
16
+ DecisionResult,
17
+ DecisionSet,
18
+ DecisionWarning,
19
+ FreshnessStatus,
20
+ _ACTION_COLORS,
21
+ _ACTION_ICONS,
22
+ )
23
+
24
+ # Keep DECISION_COLORS / DECISION_ICONS as module-level exports for callers.
25
+ DECISION_COLORS = _ACTION_COLORS
26
+ DECISION_ICONS = _ACTION_ICONS
27
+
28
+
29
+ class Decision(str, Enum):
30
+ BUY = "buy"
31
+ SKIP = "skip"
32
+ USE_SOON = "use_soon"
33
+ OPTIONAL = "optional"
34
+ COMPARE = "compare"
35
+ CONFIRM = "confirm"
36
+ WATCH = "watch"
37
+
38
+
39
+ # ── Legacy aliases for backward compatibility with existing tests ──────────
40
+ # These map the old Decision enum values to the new DecisionResult.action values.
41
+ # Used by classify_all() and _classify() when building DecisionResult items.
42
+ ACTION_MAP: dict[str, str] = {
43
+ "buy": "buy",
44
+ "skip": "skip",
45
+ "use_soon": "use_soon",
46
+ "optional": "optional",
47
+ "compare": "compare",
48
+ "confirm": "wait", # confirm → wait in new schema
49
+ "watch": "wait", # watch → wait in new schema
50
+ }
shopstack/domain/__init__.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Domain layer — pure business logic, no external dependencies.
2
+
3
+ This package extracts canonical business rules from services/UI into
4
+ testable, reusable pure functions. Each module has zero imports from
5
+ shopstack.services, shopstack.ui, or shopstack.persistence.
6
+
7
+ Supersedes scattered logic in:
8
+ - shopstack/market/normalization.py (unit price, canonical maps)
9
+ - shopstack/services/freshness.py (freshness classification)
10
+ - shopstack/services/dashboard.py (inventory alerts)
11
+ - shopstack/ui/screens/other.py (location hierarchy)
12
+ - shopstack/decisions/rules.py (decision predicates, partially)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from .unit_price import (
18
+ parse_size,
19
+ compute_unit_prices,
20
+ SizeParseResult,
21
+ CANONICAL_MAP,
22
+ ITEM_ALIASES,
23
+ resolve_canonical,
24
+ normalize_item_name,
25
+ canonicalize_name,
26
+ )
27
+ from .market_freshness import (
28
+ classify_freshness,
29
+ classify_snapshot_freshness,
30
+ inventory_freshness_label,
31
+ inventory_confidence,
32
+ needs_confirmation,
33
+ confirmation_prompt,
34
+ FreshnessReport,
35
+ )
36
+ from .inventory_alerts import (
37
+ classify_inventory_alert,
38
+ InventoryAlert,
39
+ AlertLevel,
40
+ )
41
+ from .storage_locations import (
42
+ is_parent_of,
43
+ get_location_hierarchy,
44
+ LocationNode,
45
+ )
46
+ from .product_matching import (
47
+ score_product_match,
48
+ MatchScore,
49
+ MatchReason,
50
+ )
51
+
52
+ __all__ = [
53
+ # unit_price
54
+ "parse_size",
55
+ "compute_unit_prices",
56
+ "SizeParseResult",
57
+ "CANONICAL_MAP",
58
+ "ITEM_ALIASES",
59
+ "resolve_canonical",
60
+ "normalize_item_name",
61
+ "canonicalize_name",
62
+ # market_freshness
63
+ "classify_freshness",
64
+ "classify_snapshot_freshness",
65
+ "inventory_freshness_label",
66
+ "inventory_confidence",
67
+ "needs_confirmation",
68
+ "confirmation_prompt",
69
+ "FreshnessReport",
70
+ # inventory_alerts
71
+ "classify_inventory_alert",
72
+ "InventoryAlert",
73
+ "AlertLevel",
74
+ # storage_locations
75
+ "is_parent_of",
76
+ "get_location_hierarchy",
77
+ "LocationNode",
78
+ # product_matching
79
+ "score_product_match",
80
+ "MatchScore",
81
+ "MatchReason",
82
+ ]
shopstack/market/__init__.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from .analytics import (
2
+ available_canonical_names,
3
+ compute_snapshot_analytics,
4
+ find_all_options,
5
+ find_cheapest_weight_option,
6
+ )
7
+ from .basket import (
8
+ BasketItem,
9
+ OptimizedBasket,
10
+ OptimizedBasketItem,
11
+ basket_summary,
12
+ build_basket,
13
+ build_optimized_basket,
14
+ )
15
+ from .metadata import ProduceMetadata, get_produce_metadata, use_first, waste_risk_ranking
16
+ from .schema import MarketSnapshot, NormalizedMarketRecord
17
+
18
+ __all__ = [
19
+ "MarketSnapshot",
20
+ "NormalizedMarketRecord",
21
+ "BasketItem",
22
+ "OptimizedBasket",
23
+ "OptimizedBasketItem",
24
+ "ProduceMetadata",
25
+ "compute_snapshot_analytics",
26
+ "find_cheapest_weight_option",
27
+ "find_all_options",
28
+ "available_canonical_names",
29
+ "build_basket",
30
+ "build_optimized_basket",
31
+ "basket_summary",
32
+ "get_produce_metadata",
33
+ "waste_risk_ranking",
34
+ "use_first",
35
+ ]
shopstack/market/analytics.py ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections import Counter
4
+ from typing import Any
5
+
6
+ from .schema import MarketSnapshot, NormalizedMarketRecord
7
+
8
+
9
+ def compute_snapshot_analytics(snapshot: MarketSnapshot) -> dict[str, Any]:
10
+ records = snapshot.normalized_records
11
+ n_total = len(records)
12
+
13
+ if n_total == 0:
14
+ return {
15
+ "total": 0,
16
+ "available": 0,
17
+ "sold_out": 0,
18
+ "combos": 0,
19
+ "ads": 0,
20
+ "upgrades": 0,
21
+ "avg_price": 0,
22
+ "median_price": 0,
23
+ "avg_discount": 0,
24
+ "category_counts": {},
25
+ "canonical_counts": {},
26
+ "weight_price_range": {},
27
+ "weight_price_by_canonical": {},
28
+ "best_value_by_canonical": {},
29
+ }
30
+
31
+ available = [r for r in records if r.is_available]
32
+ sold_out = [r for r in records if not r.is_available]
33
+ combos = [r for r in records if r.is_combo]
34
+ ads = [r for r in records if r.is_ad]
35
+ upgrades = [r for r in records if r.is_upgrade]
36
+
37
+ prices = [r.price_inr for r in records if r.price_inr > 0]
38
+ avg_price = sum(prices) / len(prices) if prices else 0
39
+ median_price = _median(prices) if prices else 0
40
+
41
+ discounts = [
42
+ r.computed_discount_percent
43
+ for r in records
44
+ if r.computed_discount_percent > 0
45
+ ]
46
+ avg_discount = sum(discounts) / len(discounts) if discounts else 0
47
+
48
+ # Top discounts: up to 5 records with the highest *displayed* discount,
49
+ # tied by price ascending. Restores the ``top_discounts`` field that
50
+ # the deprecated ``summarize_swiggy_snapshot`` exposed in Pass 9
51
+ # supersession (see Pass 9 addendum: "Add features back to canonical").
52
+ # Using ``discount_percent_displayed`` (the canonical field) rather
53
+ # than the legacy ``discount_percent`` (which was a custom dataclass
54
+ # field on the deprecated ``SwiggyVegetableRecord``).
55
+ top_discounts_records = [
56
+ r for r in records
57
+ if r.discount_percent_displayed is not None
58
+ and r.discount_percent_displayed > 0
59
+ ]
60
+ top_discounts_records.sort(
61
+ key=lambda r: (-r.discount_percent_displayed, r.price_inr or 0.0)
62
+ )
63
+ top_discounts = [
64
+ {
65
+ "name": r.raw_name,
66
+ "canonical_name": r.canonical_name,
67
+ "price_inr": r.price_inr,
68
+ "discount_percent": r.discount_percent_displayed,
69
+ }
70
+ for r in top_discounts_records[:5]
71
+ ]
72
+
73
+ canonical_counts: dict[str, int] = Counter(
74
+ r.canonical_name for r in records if not r.is_combo
75
+ )
76
+
77
+ weight_records = [
78
+ r
79
+ for r in records
80
+ if r.is_weight_based and r.price_per_kg is not None and not r.is_combo
81
+ ]
82
+ by_canonical: dict[str, list[float]] = {}
83
+ for r in weight_records:
84
+ if r.price_per_kg is not None:
85
+ by_canonical.setdefault(r.canonical_name, []).append(r.price_per_kg)
86
+ weight_price_by_canonical = {
87
+ k: {
88
+ "min": round(min(vals), 2),
89
+ "max": round(max(vals), 2),
90
+ "avg": round(sum(vals) / len(vals), 2),
91
+ "count": len(vals),
92
+ }
93
+ for k, vals in by_canonical.items()
94
+ }
95
+
96
+ overall_ppa = _percentile_bounds(
97
+ [r.price_per_kg for r in weight_records]
98
+ )
99
+
100
+ best_value: dict[str, NormalizedMarketRecord] = {}
101
+ for r in weight_records:
102
+ if r.price_per_kg is None:
103
+ continue
104
+ existing = best_value.get(r.canonical_name)
105
+ if existing is None or (existing.price_per_kg is not None and r.price_per_kg < existing.price_per_kg):
106
+ best_value[r.canonical_name] = r
107
+
108
+ best_value_summary = {
109
+ k: {
110
+ "price_per_kg": v.price_per_kg,
111
+ "price_inr": v.price_inr,
112
+ "raw_name": v.raw_name,
113
+ "raw_size": v.raw_size,
114
+ "is_available": v.is_available,
115
+ }
116
+ for k, v in best_value.items()
117
+ }
118
+
119
+ return {
120
+ "total": n_total,
121
+ "available": len(available),
122
+ "sold_out": len(sold_out),
123
+ "combos": len(combos),
124
+ "ads": len(ads),
125
+ "upgrades": len(upgrades),
126
+ "avg_price": round(avg_price, 2),
127
+ "median_price": round(median_price, 2),
128
+ "avg_discount": round(avg_discount, 2),
129
+ "top_discounts": top_discounts,
130
+ "category_counts": dict(Counter(r.source_category for r in records)),
131
+ "canonical_counts": dict(canonical_counts),
132
+ "weight_price_range": overall_ppa,
133
+ "weight_price_by_canonical": weight_price_by_canonical,
134
+ "best_value_by_canonical": best_value_summary,
135
+ "weight_records_count": len(weight_records),
136
+ }
137
+
138
+
139
+ def _median(values: list[float]) -> float:
140
+ if not values:
141
+ return 0
142
+ s = sorted(values)
143
+ n = len(s)
144
+ mid = n // 2
145
+ if n % 2 == 0:
146
+ return (s[mid - 1] + s[mid]) / 2
147
+ return s[mid]
148
+
149
+
150
+ def _percentile_bounds(
151
+ values: list[float | None],
152
+ ) -> dict[str, float | None]:
153
+ clean = sorted(v for v in values if v is not None and v > 0)
154
+ if not clean:
155
+ return {"min": None, "max": None, "p25": None, "p50": None, "p75": None}
156
+ n = len(clean)
157
+
158
+ def pct(p: float) -> float:
159
+ idx = max(0, min(n - 1, int(n * p) - 1 if n * p >= 1 else 0))
160
+ return round(clean[idx], 2)
161
+
162
+ return {
163
+ "min": round(clean[0], 2),
164
+ "max": round(clean[-1], 2),
165
+ "p25": pct(0.25),
166
+ "p50": pct(0.50),
167
+ "p75": pct(0.75),
168
+ }
169
+
170
+
171
+ def find_cheapest_weight_option(
172
+ snapshot: MarketSnapshot,
173
+ canonical_name: str,
174
+ available_only: bool = True,
175
+ ) -> NormalizedMarketRecord | None:
176
+ candidates = [
177
+ r
178
+ for r in snapshot.normalized_records
179
+ if r.canonical_name == canonical_name
180
+ and r.is_weight_based
181
+ and not r.is_combo
182
+ and r.price_per_kg is not None
183
+ and (r.is_available or not available_only)
184
+ ]
185
+ if not candidates:
186
+ return None
187
+ def _ppa_key(r: NormalizedMarketRecord) -> float:
188
+ v = r.price_per_kg
189
+ assert v is not None, "price_per_kg should be non-None by filter above"
190
+ return v
191
+ return min(candidates, key=_ppa_key)
192
+
193
+
194
+ def find_all_options(
195
+ snapshot: MarketSnapshot,
196
+ canonical_name: str,
197
+ available_only: bool = True,
198
+ ) -> list[NormalizedMarketRecord]:
199
+ return [
200
+ r
201
+ for r in snapshot.normalized_records
202
+ if r.canonical_name == canonical_name
203
+ and (r.is_available or not available_only)
204
+ ]
205
+
206
+
207
+ def available_canonical_names(snapshot: MarketSnapshot) -> set[str]:
208
+ return {
209
+ r.canonical_name
210
+ for r in snapshot.normalized_records
211
+ if r.is_available and not r.is_combo
212
+ }
shopstack/market/basket.py ADDED
@@ -0,0 +1,495 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import date
5
+ from typing import Any
6
+
7
+ from .analytics import (
8
+ available_canonical_names,
9
+ find_all_options,
10
+ find_cheapest_weight_option,
11
+ )
12
+ from .metadata import get_produce_metadata
13
+ from .schema import MarketSnapshot, NormalizedMarketRecord
14
+
15
+
16
+ @dataclass
17
+ class BasketItem:
18
+ requested_name: str
19
+ canonical_name: str
20
+ matched: bool
21
+ reason: str
22
+ recommended_record: NormalizedMarketRecord | None
23
+ alternatives: list[NormalizedMarketRecord]
24
+ estimated_price_inr: float | None
25
+ estimated_price_per_kg: float | None
26
+
27
+
28
+ def build_basket(
29
+ items: list[str],
30
+ snapshot: MarketSnapshot,
31
+ available_only: bool = True,
32
+ ) -> list[BasketItem]:
33
+ available = available_canonical_names(snapshot)
34
+ results: list[BasketItem] = []
35
+
36
+ for raw_item in items:
37
+ cleaned = raw_item.strip()
38
+ if not cleaned:
39
+ continue
40
+ canonical = _match_canonical(cleaned, available)
41
+ if canonical is None:
42
+ results.append(
43
+ BasketItem(
44
+ requested_name=cleaned,
45
+ canonical_name="",
46
+ matched=False,
47
+ reason="no_match_in_snapshot",
48
+ recommended_record=None,
49
+ alternatives=[],
50
+ estimated_price_inr=None,
51
+ estimated_price_per_kg=None,
52
+ )
53
+ )
54
+ continue
55
+
56
+ cheapest = find_cheapest_weight_option(snapshot, canonical, available_only)
57
+ all_opts = find_all_options(snapshot, canonical, available_only)
58
+
59
+ if cheapest is None:
60
+ non_weight = [
61
+ r
62
+ for r in all_opts
63
+ if not r.is_weight_based and not r.is_combo
64
+ ]
65
+ if non_weight:
66
+ rec = min(non_weight, key=lambda r: r.price_inr)
67
+ results.append(
68
+ BasketItem(
69
+ requested_name=cleaned,
70
+ canonical_name=canonical,
71
+ matched=True,
72
+ reason="piece_based_only",
73
+ recommended_record=rec,
74
+ alternatives=non_weight[:3],
75
+ estimated_price_inr=rec.price_inr,
76
+ estimated_price_per_kg=None,
77
+ )
78
+ )
79
+ else:
80
+ results.append(
81
+ BasketItem(
82
+ requested_name=cleaned,
83
+ canonical_name=canonical,
84
+ matched=True,
85
+ reason="no_available_weight_or_piece",
86
+ recommended_record=None,
87
+ alternatives=all_opts[:3],
88
+ estimated_price_inr=None,
89
+ estimated_price_per_kg=None,
90
+ )
91
+ )
92
+ else:
93
+ results.append(
94
+ BasketItem(
95
+ requested_name=cleaned,
96
+ canonical_name=canonical,
97
+ matched=True,
98
+ reason="weight_based_cheapest",
99
+ recommended_record=cheapest,
100
+ alternatives=[
101
+ r for r in all_opts if r is not cheapest
102
+ ][:3],
103
+ estimated_price_inr=cheapest.price_inr,
104
+ estimated_price_per_kg=cheapest.price_per_kg,
105
+ )
106
+ )
107
+
108
+ return results
109
+
110
+
111
+ def basket_summary(basket: list[BasketItem]) -> dict[str, Any]:
112
+ matched = [b for b in basket if b.matched]
113
+ unmatched = [b for b in basket if not b.matched]
114
+ total_estimated = sum(
115
+ b.estimated_price_inr for b in matched if b.estimated_price_inr
116
+ )
117
+ return {
118
+ "total_requested": len(basket),
119
+ "matched": len(matched),
120
+ "unmatched": len(unmatched),
121
+ "total_estimated_price_inr": round(total_estimated, 2),
122
+ "unmatched_items": [b.requested_name for b in unmatched],
123
+ }
124
+
125
+
126
+ # ─── Decision-Aware Basket Optimizer ───────────────────────────────────────
127
+
128
+
129
+ @dataclass
130
+ class OptimizedBasketItem:
131
+ requested_name: str
132
+ canonical_name: str
133
+ decision: str # "buy" | "skip" | "use_soon" | "compare" | "unavailable"
134
+ reason_type: str # "enough_stock" | "waste_risk" | "stale_data" | "no_availability" | "price_low" | "price_high" | "inventory_subtracted"
135
+ reason: str
136
+ matched: bool
137
+ requested_quantity: float = 1.0
138
+ unit: str = "unit"
139
+ already_owned_quantity: float = 0.0
140
+ net_quantity_to_buy: float = 0.0
141
+ recommended_record: NormalizedMarketRecord | None = None
142
+ alternatives: list[NormalizedMarketRecord] = field(default_factory=list)
143
+ estimated_price_inr: float | None = None
144
+ estimated_price_per_kg: float | None = None
145
+ waste_risk: str = "unknown"
146
+ freshness_note: str = ""
147
+
148
+
149
+ @dataclass
150
+ class OptimizedBasket:
151
+ items: list[OptimizedBasketItem] = field(default_factory=list)
152
+
153
+ @property
154
+ def buy(self) -> list[OptimizedBasketItem]:
155
+ return [i for i in self.items if i.decision == "buy"]
156
+
157
+ @property
158
+ def skip(self) -> list[OptimizedBasketItem]:
159
+ return [i for i in self.items if i.decision == "skip"]
160
+
161
+ @property
162
+ def use_soon(self) -> list[OptimizedBasketItem]:
163
+ return [i for i in self.items if i.decision == "use_soon"]
164
+
165
+ @property
166
+ def total_estimated(self) -> float:
167
+ return round(sum(
168
+ i.estimated_price_inr or 0 for i in self.buy
169
+ ), 2)
170
+
171
+ @property
172
+ def summary(self) -> dict[str, Any]:
173
+ return {
174
+ "total_requested": len(self.items),
175
+ "buy": len(self.buy),
176
+ "skip": len(self.skip),
177
+ "use_soon": len(self.use_soon),
178
+ "total_estimated_price_inr": self.total_estimated,
179
+ }
180
+
181
+
182
+ def _normalize_unit_to_grams(qty: float, unit: str) -> float:
183
+ """Convert a quantity in user-facing units to grams for price calculation."""
184
+ u = unit.lower().strip()
185
+ if u in ("kg", "kilo", "kilos", "kilogram", "kilograms"):
186
+ return qty * 1000
187
+ if u in ("g", "gram", "grams"):
188
+ return qty
189
+ if u in ("l", "litre", "liter", "litres", "liters"):
190
+ return qty * 1000
191
+ if u in ("ml", "milliliter", "millilitre"):
192
+ return qty
193
+ # For piece-based items, return as-is (matched against piece records)
194
+ return qty
195
+
196
+
197
+ def build_optimized_basket(
198
+ requested_items: list[dict[str, Any]],
199
+ snapshot: MarketSnapshot,
200
+ inventory_map: dict[str, float] | None = None,
201
+ available_only: bool = True,
202
+ budget_inr: float | None = None,
203
+ household_size: int = 1,
204
+ days_to_plan: int = 3,
205
+ avoid_items: list[str] | None = None,
206
+ ) -> OptimizedBasket:
207
+ """Build a decision-aware basket from requested items and market snapshot.
208
+
209
+ Each item is classified into buy / skip / use_soon / compare / unavailable
210
+ with an explicit ``reason_type`` explaining the classification logic.
211
+
212
+ Args:
213
+ requested_items: List of dicts with ``canonical_name``, ``requested_quantity``, ``unit``.
214
+ snapshot: Market snapshot with normalized records.
215
+ inventory_map: Map of canonical_name -> total quantity owned (inventory subtraction).
216
+ available_only: If True, only consider available items when recommending.
217
+ budget_inr: Optional budget cap in INR. If set, items beyond budget get ``over_budget`` reason_type.
218
+ household_size: Number of people. Adjusts recommended quantities.
219
+ days_to_plan: Number of days to plan for. Adjusts recommended quantities.
220
+ avoid_items: List of canonical names the household wants to avoid.
221
+
222
+ Returns:
223
+ An ``OptimizedBasket`` with per-item classifications.
224
+ """
225
+ inventory_map = inventory_map or {}
226
+ avoid_set = {a.lower().strip() for a in (avoid_items or [])}
227
+ available = available_canonical_names(snapshot)
228
+ snapshot_date = _parse_date(snapshot.captured_at) if snapshot.captured_at else date.today()
229
+ age_days = (date.today() - snapshot_date).days if snapshot_date else 0
230
+ is_stale = age_days > 1
231
+ freshness_note = f"Snapshot {age_days}d old" if is_stale else "Today's data"
232
+
233
+ results: list[OptimizedBasketItem] = []
234
+ running_total = 0.0
235
+
236
+ for raw in requested_items:
237
+ name = raw.get("canonical_name", "").strip()
238
+ if not name:
239
+ continue
240
+ qty = float(raw.get("requested_quantity", 1.0) or 1.0)
241
+ unit = raw.get("unit", "unit") or "unit"
242
+
243
+ # Inventory subtraction
244
+ owned = inventory_map.get(name.lower(), 0.0)
245
+ net_needed = max(qty - owned, 0.0)
246
+
247
+ # Match to snapshot
248
+ canonical = _match_canonical(name, available)
249
+ if canonical is None:
250
+ results.append(OptimizedBasketItem(
251
+ requested_name=name,
252
+ canonical_name=name,
253
+ decision="unavailable",
254
+ reason_type="no_availability",
255
+ reason=f"No market data found for {name}",
256
+ matched=False,
257
+ requested_quantity=qty,
258
+ unit=unit,
259
+ already_owned_quantity=owned,
260
+ net_quantity_to_buy=qty,
261
+ freshness_note=freshness_note,
262
+ ))
263
+ continue
264
+
265
+ # Produce metadata for waste risk
266
+ meta = _get_produce_meta(canonical)
267
+ waste_risk = meta.waste_risk if meta else "unknown"
268
+ shelf_life = meta.shelf_life_days if meta else 0
269
+
270
+ # Check for "use soon" signal from produce metadata
271
+ if owned > 0 and shelf_life > 0 and shelf_life <= 5:
272
+ results.append(OptimizedBasketItem(
273
+ requested_name=name,
274
+ canonical_name=canonical,
275
+ decision="use_soon",
276
+ reason_type="waste_risk",
277
+ reason=f"Use existing {owned} {unit} before buying more",
278
+ matched=True,
279
+ requested_quantity=qty,
280
+ unit=unit,
281
+ already_owned_quantity=owned,
282
+ net_quantity_to_buy=0,
283
+ waste_risk=waste_risk,
284
+ freshness_note=freshness_note,
285
+ ))
286
+ continue
287
+
288
+ # Skip if enough already owned
289
+ if net_needed <= 0:
290
+ results.append(OptimizedBasketItem(
291
+ requested_name=name,
292
+ canonical_name=canonical,
293
+ decision="skip",
294
+ reason_type="enough_stock",
295
+ reason=f"Already have {owned} {unit} at home",
296
+ matched=True,
297
+ requested_quantity=qty,
298
+ unit=unit,
299
+ already_owned_quantity=owned,
300
+ net_quantity_to_buy=0,
301
+ waste_risk=waste_risk,
302
+ freshness_note=freshness_note,
303
+ ))
304
+ continue
305
+
306
+ # Waste risk for high-waste items
307
+ if waste_risk == "high" and owned > 0:
308
+ results.append(OptimizedBasketItem(
309
+ requested_name=name,
310
+ canonical_name=canonical,
311
+ decision="skip",
312
+ reason_type="waste_risk",
313
+ reason=f"High waste risk — you have {owned} {unit} already",
314
+ matched=True,
315
+ requested_quantity=qty,
316
+ unit=unit,
317
+ already_owned_quantity=owned,
318
+ net_quantity_to_buy=0,
319
+ waste_risk=waste_risk,
320
+ freshness_note=freshness_note,
321
+ ))
322
+ continue
323
+
324
+ # Stale data warning
325
+ if is_stale and not available_only:
326
+ results.append(OptimizedBasketItem(
327
+ requested_name=name,
328
+ canonical_name=canonical,
329
+ decision="compare",
330
+ reason_type="stale_data",
331
+ reason=f"Market data {age_days} days old — verify prices before checkout",
332
+ matched=True,
333
+ requested_quantity=qty,
334
+ unit=unit,
335
+ already_owned_quantity=owned,
336
+ net_quantity_to_buy=net_needed,
337
+ waste_risk=waste_risk,
338
+ freshness_note=freshness_note,
339
+ ))
340
+ continue
341
+
342
+ # Skip items the household avoids
343
+ if name.lower().strip() in avoid_set:
344
+ results.append(OptimizedBasketItem(
345
+ requested_name=name,
346
+ canonical_name=canonical,
347
+ decision="skip",
348
+ reason_type="household_avoids",
349
+ reason=f"{name.title().replace('_', ' ')} is on your household avoid list",
350
+ matched=True,
351
+ requested_quantity=qty,
352
+ unit=unit,
353
+ already_owned_quantity=owned,
354
+ net_quantity_to_buy=0,
355
+ waste_risk=waste_risk or "unknown",
356
+ freshness_note=freshness_note,
357
+ ))
358
+ continue
359
+
360
+ # Scale quantity by household size and days to plan
361
+ scaled_qty = qty * household_size * (days_to_plan / 3.0) if household_size > 0 else qty
362
+ net_needed = max(scaled_qty - owned, 0.0)
363
+
364
+ # Find cheapest market option
365
+ cheapest = find_cheapest_weight_option(snapshot, canonical, available_only)
366
+ all_opts = find_all_options(snapshot, canonical, available_only)
367
+
368
+ if cheapest is not None:
369
+ # Convert net_needed to grams to match normalized_quantity (always in grams)
370
+ net_needed_grams = _normalize_unit_to_grams(net_needed, unit)
371
+ nq = cheapest.normalized_quantity
372
+ if nq is not None and nq > 0 and net_needed_grams > 0:
373
+ _price = cheapest.price_inr * (net_needed_grams / nq)
374
+ else:
375
+ _price = cheapest.price_inr
376
+
377
+ # Budget cap check
378
+ if budget_inr is not None and running_total + _price > budget_inr:
379
+ results.append(OptimizedBasketItem(
380
+ requested_name=name,
381
+ canonical_name=canonical,
382
+ decision="compare",
383
+ reason_type="over_budget",
384
+ reason=f"Exceeds remaining budget of \u20b9{budget_inr - running_total:.0f} (\u20b9{_price:.0f} needed)",
385
+ matched=True,
386
+ requested_quantity=qty,
387
+ unit=unit,
388
+ already_owned_quantity=owned,
389
+ net_quantity_to_buy=net_needed,
390
+ recommended_record=cheapest,
391
+ alternatives=[r for r in all_opts if r is not cheapest][:3],
392
+ estimated_price_inr=cheapest.price_inr,
393
+ estimated_price_per_kg=cheapest.price_per_kg,
394
+ waste_risk=waste_risk,
395
+ freshness_note=freshness_note,
396
+ ))
397
+ continue
398
+ running_total += _price
399
+
400
+ results.append(OptimizedBasketItem(
401
+ requested_name=name,
402
+ canonical_name=canonical,
403
+ decision="buy",
404
+ reason_type="price_low",
405
+ reason=f"Buy {net_needed:.1f} {unit} at \u20b9{cheapest.price_inr:.0f} (\u20b9{cheapest.price_per_kg:.0f}/kg)",
406
+ matched=True,
407
+ requested_quantity=qty,
408
+ unit=unit,
409
+ already_owned_quantity=owned,
410
+ net_quantity_to_buy=net_needed,
411
+ recommended_record=cheapest,
412
+ alternatives=[r for r in all_opts if r is not cheapest][:3],
413
+ estimated_price_inr=cheapest.price_inr,
414
+ estimated_price_per_kg=cheapest.price_per_kg,
415
+ waste_risk=waste_risk,
416
+ freshness_note=freshness_note,
417
+ ))
418
+ else:
419
+ # No weight-based option found — try piece-based
420
+ non_weight = [
421
+ r for r in all_opts
422
+ if not r.is_weight_based and not r.is_combo
423
+ ]
424
+ if non_weight:
425
+ rec = min(non_weight, key=lambda r: r.price_inr)
426
+ results.append(OptimizedBasketItem(
427
+ requested_name=name,
428
+ canonical_name=canonical,
429
+ decision="buy",
430
+ reason_type="price_low",
431
+ reason=f"Buy {net_needed:.1f} {unit} — piece-based at \u20b9{rec.price_inr:.0f}",
432
+ matched=True,
433
+ requested_quantity=qty,
434
+ unit=unit,
435
+ already_owned_quantity=owned,
436
+ net_quantity_to_buy=net_needed,
437
+ recommended_record=rec,
438
+ alternatives=non_weight[:3],
439
+ estimated_price_inr=rec.price_inr,
440
+ waste_risk=waste_risk,
441
+ freshness_note=freshness_note,
442
+ ))
443
+ else:
444
+ results.append(OptimizedBasketItem(
445
+ requested_name=name,
446
+ canonical_name=canonical,
447
+ decision="unavailable",
448
+ reason_type="no_availability",
449
+ reason="No available weight or piece options",
450
+ matched=True,
451
+ requested_quantity=qty,
452
+ unit=unit,
453
+ already_owned_quantity=owned,
454
+ net_quantity_to_buy=net_needed,
455
+ freshness_note=freshness_note,
456
+ ))
457
+
458
+ return OptimizedBasket(items=results)
459
+
460
+
461
+ def _parse_date(value: str) -> date | None:
462
+ try:
463
+ return date.fromisoformat(value[:10])
464
+ except (ValueError, TypeError):
465
+ return None
466
+
467
+
468
+ def _get_produce_meta(canonical_name: str):
469
+ try:
470
+ return get_produce_metadata(canonical_name)
471
+ except Exception:
472
+ return None
473
+
474
+
475
+ def _match_canonical(query: str, available: set[str]) -> str | None:
476
+ lowered = query.lower().strip()
477
+ if lowered in available:
478
+ return lowered
479
+
480
+ from shopstack.market.normalization import resolve_canonical
481
+ mapped = resolve_canonical(lowered)
482
+ if mapped and mapped in available:
483
+ return mapped
484
+
485
+ if mapped:
486
+ for a in available:
487
+ if mapped in a or a in mapped:
488
+ return a
489
+
490
+ partial = [
491
+ a for a in available if lowered in a or a in lowered
492
+ ]
493
+ if partial:
494
+ return partial[0]
495
+ return None
shopstack/market/combo.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Dict, List, Optional, Tuple
2
+ from shopstack.market.schema import NormalizedMarketRecord
3
+ from shopstack.catalog.models import WasteRisk
4
+ from shopstack.catalog.service import CatalogService
5
+
6
+ class ComboEvaluator:
7
+ def __init__(self, catalog_service: CatalogService):
8
+ self.catalog = catalog_service
9
+
10
+ def evaluate_combo(
11
+ self,
12
+ combo_record: NormalizedMarketRecord,
13
+ individual_options: Dict[str, NormalizedMarketRecord],
14
+ user_inventory: List[str]
15
+ ) -> Dict[str, any]:
16
+ """
17
+ Evaluates a combo against buying individual items.
18
+ Returns a dictionary with savings, waste risk, and recommendation.
19
+ """
20
+ if not combo_record.is_combo:
21
+ return {"error": "Not a combo record"}
22
+
23
+ components = combo_record.component_names
24
+ if not components:
25
+ return {"error": "No components detected in combo"}
26
+
27
+ total_individual_price = 0.0
28
+ missing_individual_price = False
29
+ component_waste_risks = []
30
+ already_owned = []
31
+
32
+ for comp in components:
33
+ # Calculate waste risk if user already owns it
34
+ if comp in user_inventory:
35
+ already_owned.append(comp)
36
+ entry = self.catalog.get_by_canonical_id(comp)
37
+ if entry:
38
+ component_waste_risks.append(entry.waste_risk.value)
39
+ else:
40
+ component_waste_risks.append(WasteRisk.MEDIUM.value)
41
+
42
+ # Sum individual prices
43
+ if comp in individual_options and individual_options[comp].is_available:
44
+ total_individual_price += individual_options[comp].price_inr
45
+ else:
46
+ missing_individual_price = True
47
+
48
+ # Savings calculation
49
+ savings = 0.0
50
+ combo_value_ratio = 1.0
51
+ if not missing_individual_price and total_individual_price > 0:
52
+ savings = total_individual_price - combo_record.price_inr
53
+ combo_value_ratio = combo_record.price_inr / total_individual_price
54
+
55
+ # Waste risk calculation
56
+ overall_waste_risk = WasteRisk.LOW.value
57
+ if already_owned:
58
+ if WasteRisk.HIGH.value in component_waste_risks:
59
+ overall_waste_risk = WasteRisk.HIGH.value
60
+ else:
61
+ overall_waste_risk = WasteRisk.MEDIUM.value
62
+
63
+ recommendation = "neutral"
64
+ if overall_waste_risk == WasteRisk.HIGH.value:
65
+ recommendation = "skip_due_to_waste_risk"
66
+ elif savings > 0:
67
+ recommendation = "buy_combo"
68
+ elif not missing_individual_price and savings < 0:
69
+ recommendation = "buy_individuals"
70
+
71
+ return {
72
+ "components": components,
73
+ "total_individual_price": total_individual_price if not missing_individual_price else None,
74
+ "combo_price": combo_record.price_inr,
75
+ "savings": savings if not missing_individual_price else None,
76
+ "value_ratio": combo_value_ratio if not missing_individual_price else None,
77
+ "already_owned_components": already_owned,
78
+ "waste_risk": overall_waste_risk,
79
+ "recommendation": recommendation
80
+ }
shopstack/market/comparison.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import dataclass
2
+ from datetime import date
3
+ from typing import List, Optional
4
+
5
+ from shopstack.market.schema import NormalizedMarketRecord
6
+
7
+
8
+ @dataclass
9
+ class ComparisonResult:
10
+ canonical_name: str
11
+ best_record: Optional[NormalizedMarketRecord]
12
+ all_records: List[NormalizedMarketRecord]
13
+ freshness_warnings: List[str]
14
+ ad_warnings: List[str]
15
+ confidence_score: float
16
+
17
+
18
+ def compare_records(
19
+ canonical_name: str,
20
+ records: List[NormalizedMarketRecord],
21
+ today: Optional[date] = None
22
+ ) -> ComparisonResult:
23
+ """Compare multiple market records to find the best option."""
24
+ if today is None:
25
+ today = date.today()
26
+
27
+ best_record = None
28
+ freshness_warnings = []
29
+ ad_warnings = []
30
+ confidence_score = 1.0
31
+
32
+ if not records:
33
+ return ComparisonResult(
34
+ canonical_name=canonical_name,
35
+ best_record=None,
36
+ all_records=[],
37
+ freshness_warnings=[],
38
+ ad_warnings=[],
39
+ confidence_score=0.0
40
+ )
41
+
42
+ # Filter out unavailable items
43
+ available_records = [r for r in records if r.is_available]
44
+ if not available_records:
45
+ confidence_score -= 0.5
46
+
47
+ # Check for ads and freshness
48
+ for rec in records:
49
+ if rec.is_ad:
50
+ ad_warnings.append(f"Record from {rec.source} is marked as an ad.")
51
+ try:
52
+ captured_date = date.fromisoformat(rec.captured_at[:10])
53
+ if (today - captured_date).days > 1:
54
+ freshness_warnings.append(
55
+ f"Data from {rec.source} is {(today - captured_date).days} days old."
56
+ )
57
+ except (ValueError, TypeError):
58
+ freshness_warnings.append(f"Invalid date format for record from {rec.source}.")
59
+ confidence_score -= 0.1
60
+
61
+ # Sort by price_per_kg or price_inr
62
+ if available_records:
63
+ def sort_key(r: NormalizedMarketRecord):
64
+ # Prefer price_per_kg if available, else use price_inr / normalized_quantity
65
+ if r.price_per_kg:
66
+ return r.price_per_kg
67
+ if r.price_per_100g:
68
+ return r.price_per_100g * 10
69
+ if r.price_per_piece:
70
+ return r.price_per_piece
71
+ if r.normalized_quantity and r.normalized_quantity > 0:
72
+ return r.price_inr / r.normalized_quantity
73
+ return r.price_inr
74
+
75
+ sorted_records = sorted(available_records, key=sort_key)
76
+ best_record = sorted_records[0]
77
+
78
+ # Penalize confidence if best record is an ad or stale
79
+ if best_record.is_ad:
80
+ confidence_score -= 0.2
81
+ try:
82
+ captured_date = date.fromisoformat(best_record.captured_at[:10])
83
+ if (today - captured_date).days > 1:
84
+ confidence_score -= 0.3
85
+ except Exception:
86
+ pass
87
+
88
+ if len(sorted_records) < 2:
89
+ confidence_score -= 0.1 # Less confidence if only one source
90
+
91
+ return ComparisonResult(
92
+ canonical_name=canonical_name,
93
+ best_record=best_record,
94
+ all_records=records,
95
+ freshness_warnings=list(set(freshness_warnings)),
96
+ ad_warnings=list(set(ad_warnings)),
97
+ confidence_score=max(0.0, min(1.0, confidence_score))
98
+ )
shopstack/market/metadata.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class ProduceMetadata:
8
+ canonical_name: str
9
+ shelf_life_days: int
10
+ storage: str
11
+ waste_risk: str
12
+ use_priority: int
13
+ tips: str
14
+
15
+
16
+ _PRODUCE: dict[str, ProduceMetadata] = {
17
+ "tomato": ProduceMetadata("tomato", 7, "counter", "medium", 2, "Use in cooking within 5 days for best flavor."),
18
+ "onion": ProduceMetadata("onion", 30, "cool_dry", "low", 5, "Store ventilated, away from potatoes."),
19
+ "potato": ProduceMetadata("potato", 21, "cool_dark", "low", 4, "Keep away from onions to prevent sprouting."),
20
+ "baby_potato": ProduceMetadata("baby_potato", 14, "cool_dark", "low", 4, "Use within a week for best texture."),
21
+ "sweet_potato": ProduceMetadata("sweet_potato", 21, "cool_dark", "low", 4, "Cure 10 days at room temp for sweetness."),
22
+ "carrot": ProduceMetadata("carrot", 14, "fridge_crisper", "medium", 3, "Remove greens before storing."),
23
+ "cucumber": ProduceMetadata("cucumber", 7, "fridge_crisper", "high", 1, "Use within 4 days, shrivels quickly."),
24
+ "brinjal": ProduceMetadata("brinjal", 5, "fridge_crisper", "high", 1, "Browns fast once cut — use immediately."),
25
+ "capsicum": ProduceMetadata("capsicum", 10, "fridge_crisper", "medium", 3, "Stays crisp in a paper bag."),
26
+ "bell_pepper": ProduceMetadata("bell_pepper", 10, "fridge_crisper", "medium", 3, "Red/yellow spoil faster than green."),
27
+ "cauliflower": ProduceMetadata("cauliflower", 7, "fridge_crisper", "high", 2, "Brown spots = early spoilage."),
28
+ "broccoli": ProduceMetadata("broccoli", 5, "fridge_crisper", "high", 1, "Yellow florets = over the hill."),
29
+ "ridge_gourd": ProduceMetadata("ridge_gourd", 4, "fridge_crisper", "high", 1, "Use quickly, goes limp fast."),
30
+ "bottle_gourd": ProduceMetadata("bottle_gourd", 5, "fridge_crisper", "high", 2, "Cut away bitter portions."),
31
+ "bitter_gourd": ProduceMetadata("bitter_gourd", 5, "fridge_crisper", "high", 2, "Wrap loosely in paper."),
32
+ "snake_gourd": ProduceMetadata("snake_gourd", 4, "fridge_crisper", "high", 1, "Use within 2 days of purchase."),
33
+ "pointed_gourd": ProduceMetadata("pointed_gourd", 4, "fridge_crisper", "high", 1, "Delicate, use quickly."),
34
+ "round_gourd": ProduceMetadata("round_gourd", 4, "fridge_crisper", "high", 1, "Best same-day."),
35
+ "coccinia": ProduceMetadata("coccinia", 5, "fridge_crisper", "medium", 2, "Good in stir-fry."),
36
+ "cluster_beans": ProduceMetadata("cluster_beans", 4, "fridge_crisper", "high", 1, "String before cooking."),
37
+ "french_beans": ProduceMetadata("french_beans", 5, "fridge_crisper", "high", 2, "Snap off ends before storing."),
38
+ "ladys_finger": ProduceMetadata("ladys_finger", 4, "fridge_crisper", "high", 1, "Keep dry, moisture causes slime."),
39
+ "drumstick": ProduceMetadata("drumstick", 5, "fridge_crisper", "medium", 2, "Use in sambar or curry."),
40
+ "beetroot": ProduceMetadata("beetroot", 14, "fridge_crisper", "low", 4, "Leaves edible, cook like spinach."),
41
+ "radish": ProduceMetadata("radish", 7, "fridge_crisper", "low", 3, "Remove leaves before storing."),
42
+ "raw_banana": ProduceMetadata("raw_banana", 5, "counter", "medium", 2, "Ripens at room temp."),
43
+ "raw_mango": ProduceMetadata("raw_mango", 7, "counter", "medium", 2, "Use in pickles or curries."),
44
+ "yam": ProduceMetadata("yam", 14, "cool_dark", "low", 4, "Peel deeply to remove oxalates."),
45
+ "colocasia": ProduceMetadata("colocasia", 14, "cool_dark", "low", 4, "Boil before peeling."),
46
+ "zucchini": ProduceMetadata("zucchini", 7, "fridge_crisper", "medium", 3, "Pat dry before storing."),
47
+ "red_cabbage": ProduceMetadata("red_cabbage", 14, "fridge_crisper", "low", 4, "Lasts longer than green."),
48
+ "coconut": ProduceMetadata("coconut", 30, "fridge_crisper", "low", 5, "Crack within 2 days for best water."),
49
+ "curry_leaves": ProduceMetadata("curry_leaves", 7, "fridge_crisper", "high", 2, "Strip leaves from stem, freeze extras."),
50
+ "coriander": ProduceMetadata("coriander", 4, "fridge_crisper", "high", 1, "Wrap in paper towel, use early."),
51
+ "mint": ProduceMetadata("mint", 4, "fridge_crisper", "high", 1, "Like coriander, wilts fast."),
52
+ "green_chilli": ProduceMetadata("green_chilli", 10, "fridge_crisper", "medium", 3, "Stems on until use."),
53
+ "garlic": ProduceMetadata("garlic", 60, "cool_dry", "low", 5, "Unpeeled bulbs last months."),
54
+ "ginger": ProduceMetadata("ginger", 30, "fridge_crisper", "low", 5, "Freeze for longer storage."),
55
+ "sambar_onion": ProduceMetadata("sambar_onion", 21, "cool_dry", "low", 4, "Shallots, store like onions."),
56
+ "white_onion": ProduceMetadata("white_onion", 30, "cool_dry", "low", 5, "Milder than red onion."),
57
+ }
58
+
59
+
60
+ def get_produce_metadata(canonical_name: str) -> ProduceMetadata | None:
61
+ return _PRODUCE.get(canonical_name)
62
+
63
+
64
+ def waste_risk_ranking() -> list[str]:
65
+ items = list(_PRODUCE.values())
66
+ items.sort(key=lambda p: (p.use_priority, -p.shelf_life_days))
67
+ return [p.canonical_name for p in items]
68
+
69
+
70
+ def use_first(items: list[str]) -> list[str]:
71
+ ranked = []
72
+ for name in items:
73
+ meta = _PRODUCE.get(name)
74
+ if meta:
75
+ ranked.append((name, meta.use_priority))
76
+ else:
77
+ ranked.append((name, 99))
78
+ ranked.sort(key=lambda x: x[1])
79
+ return [name for name, _ in ranked]
shopstack/market/normalization.py ADDED
@@ -0,0 +1,447 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ from dataclasses import dataclass
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ _WEIGHT_PATTERN = re.compile(
11
+ r"^(\d+(?:\.\d+)?)\s*(kg|g|ml|l|liter|litre)\s*(?:x\s*(\d+))?$",
12
+ re.IGNORECASE,
13
+ )
14
+ _PIECE_PATTERN = re.compile(
15
+ r"^(\d+)\s*(?:pieces|piece|pcs|pc)\s*(?:x\s*(\d+))?$",
16
+ re.IGNORECASE,
17
+ )
18
+ _COMBO_PATTERN = re.compile(r"^(\d+)\s*(?:combo|pack)$", re.IGNORECASE)
19
+ _SIZE_CLASS_PATTERN = re.compile(
20
+ r"^(\d+)\s*(small|medium|large)$",
21
+ re.IGNORECASE,
22
+ )
23
+ _SIZE_CLASS_GRAM_ESTIMATES: dict[str, int] = {
24
+ "small": 80,
25
+ "medium": 120,
26
+ "large": 180,
27
+ }
28
+
29
+
30
+ @dataclass
31
+ class SizeParseResult:
32
+ normalized_quantity: float | None = None
33
+ normalized_unit: str | None = None
34
+ package_count: int = 1
35
+ is_weight_based: bool = False
36
+ is_piece_based: bool = False
37
+ is_combo: bool = False
38
+ is_pack: bool = False
39
+ is_size_class: bool = False
40
+ size_class: str = ""
41
+ warnings: list[str] | None = None
42
+
43
+ def __post_init__(self):
44
+ if self.warnings is None:
45
+ self.warnings = []
46
+
47
+
48
+ def parse_size(raw_size: str) -> SizeParseResult:
49
+ if not raw_size:
50
+ return SizeParseResult(warnings=["empty_size"])
51
+
52
+ stripped = raw_size.strip()
53
+
54
+ m = _WEIGHT_PATTERN.match(stripped)
55
+ if m:
56
+ qty = float(m.group(1))
57
+ unit_raw = m.group(2).lower()
58
+ pkg = int(m.group(3)) if m.group(3) else 1
59
+ unit = _normalize_weight_unit(unit_raw)
60
+ if unit_raw in ("kg", "l", "liter", "litre"):
61
+ qty = qty * 1000
62
+ total_qty = qty * pkg
63
+ return SizeParseResult(
64
+ normalized_quantity=total_qty,
65
+ normalized_unit=unit,
66
+ package_count=pkg,
67
+ is_weight_based=True,
68
+ )
69
+
70
+ m = _PIECE_PATTERN.match(stripped)
71
+ if m:
72
+ qty = int(m.group(1))
73
+ pkg = int(m.group(2)) if m.group(2) else 1
74
+ total_pieces = qty * pkg
75
+ return SizeParseResult(
76
+ normalized_quantity=float(total_pieces),
77
+ normalized_unit="pieces",
78
+ package_count=pkg,
79
+ is_piece_based=True,
80
+ )
81
+
82
+ m = _COMBO_PATTERN.match(stripped)
83
+ if m:
84
+ is_pack = "pack" in stripped.lower()
85
+ return SizeParseResult(
86
+ is_combo=not is_pack,
87
+ is_pack=is_pack,
88
+ warnings=["combo_or_pack_no_weight"],
89
+ )
90
+
91
+ m = _SIZE_CLASS_PATTERN.match(stripped)
92
+ if m:
93
+ count = int(m.group(1))
94
+ cls = m.group(2).lower()
95
+ estimated_grams = count * _SIZE_CLASS_GRAM_ESTIMATES[cls]
96
+ return SizeParseResult(
97
+ normalized_quantity=float(estimated_grams),
98
+ normalized_unit="g",
99
+ package_count=count,
100
+ is_size_class=True,
101
+ is_weight_based=True,
102
+ size_class=cls,
103
+ warnings=[f"estimated_size_class_weight:{cls}:{_SIZE_CLASS_GRAM_ESTIMATES[cls]}g_each"],
104
+ )
105
+
106
+ if re.match(r"^\d+\s*$", stripped):
107
+ n = int(stripped)
108
+ return SizeParseResult(
109
+ normalized_quantity=float(n),
110
+ normalized_unit="pieces",
111
+ package_count=n,
112
+ is_piece_based=True,
113
+ )
114
+
115
+ return SizeParseResult(warnings=[f"unrecognized_size:{stripped}"])
116
+
117
+
118
+ def _normalize_weight_unit(unit: str) -> str:
119
+ mapping = {
120
+ "kg": "g",
121
+ "g": "g",
122
+ "l": "mL",
123
+ "ml": "mL",
124
+ "liter": "mL",
125
+ "litre": "mL",
126
+ }
127
+ return mapping.get(unit, unit)
128
+
129
+
130
+ def compute_unit_prices(
131
+ price: float,
132
+ quantity: float | None,
133
+ unit: str | None,
134
+ is_weight_based: bool,
135
+ is_piece_based: bool,
136
+ ) -> dict[str, float | None]:
137
+ result: dict[str, float | None] = {
138
+ "price_per_kg": None,
139
+ "price_per_100g": None,
140
+ "price_per_piece": None,
141
+ }
142
+ if price <= 0 or quantity is None or quantity <= 0:
143
+ return result
144
+
145
+ if is_weight_based and unit == "g":
146
+ result["price_per_kg"] = round(price / quantity * 1000, 2)
147
+ result["price_per_100g"] = round(price / quantity * 100, 2)
148
+ elif is_piece_based and unit == "pieces":
149
+ result["price_per_piece"] = round(price / quantity, 2)
150
+
151
+ return result
152
+
153
+
154
+ _CANONICAL_MAP: dict[str, str] = {
155
+ "tomato": "tomato",
156
+ "indian tomato": "tomato",
157
+ "hybrid tomato": "tomato",
158
+ "desi tomato": "tomato",
159
+ "snibs snack tomatoes": "tomato",
160
+ "cherry tomato": "tomato",
161
+ "onion": "onion",
162
+ "sambar onion": "sambar_onion",
163
+ "white onion": "white_onion",
164
+ "value pack onion": "onion",
165
+ "potato": "potato",
166
+ "baby potato": "baby_potato",
167
+ "chandramukhi potato": "potato",
168
+ "sweet potato": "sweet_potato",
169
+ "carrot": "carrot",
170
+ "ooty carrot": "carrot",
171
+ "cucumber": "cucumber",
172
+ "english cucumber": "cucumber",
173
+ "green cucumber": "cucumber",
174
+ "brinjal": "brinjal",
175
+ "kateri brinjal": "brinjal",
176
+ "long purple brinjal": "brinjal",
177
+ "capsicum": "capsicum",
178
+ "green capsicum": "capsicum",
179
+ "bell pepper": "bell_pepper",
180
+ "red bell pepper": "bell_pepper",
181
+ "yellow bell pepper": "bell_pepper",
182
+ "red & yellow bell pepper": "bell_pepper",
183
+ "ridge gourd": "ridge_gourd",
184
+ "bottle gourd": "bottle_gourd",
185
+ "bitter gourd": "bitter_gourd",
186
+ "forest bitter gourd": "bitter_gourd",
187
+ "snake gourd": "snake_gourd",
188
+ "pointed gourd": "pointed_gourd",
189
+ "round gourd": "round_gourd",
190
+ "cauliflower": "cauliflower",
191
+ "coccinia": "coccinia",
192
+ "cluster beans": "cluster_beans",
193
+ "french beans": "french_beans",
194
+ "haricot beans": "haricot_beans",
195
+ "broad beans": "broad_beans",
196
+ "cowpea beans": "cowpea_beans",
197
+ "ladys finger": "ladys_finger",
198
+ "lady finger": "ladys_finger",
199
+ "okra": "ladys_finger",
200
+ "drumstick": "drumstick",
201
+ "beetroot": "beetroot",
202
+ "radish": "radish",
203
+ "white radish": "radish",
204
+ "raw banana": "raw_banana",
205
+ "raw mango": "raw_mango",
206
+ "totapuri raw mango": "raw_mango",
207
+ "yam": "yam",
208
+ "colocasia": "colocasia",
209
+ "arvi": "colocasia",
210
+ "broccoli": "broccoli",
211
+ "zucchini": "zucchini",
212
+ "green zucchini": "zucchini",
213
+ "yellow zucchini": "zucchini",
214
+ "coconut": "coconut",
215
+ "red cabbage": "red_cabbage",
216
+ "curry leaves": "curry_leaves",
217
+ "coriander leaves": "coriander",
218
+ "mint leaves": "mint",
219
+ "green chilli": "green_chilli",
220
+ "garlic": "garlic",
221
+ "ginger": "ginger",
222
+ }
223
+
224
+ _COMBO_KEYWORDS = ("combo", "&", "mix")
225
+
226
+ # User-facing alias map: Hindi/regional/colloquial → canonical English name.
227
+ # This is the single source of truth for linguistic aliases.
228
+ # Market product variants (e.g. "baby potato", "sambar onion") are NOT here
229
+ # because they represent distinct inventory items — those live in _CANONICAL_MAP.
230
+ # User-facing alias map: Hindi/regional/colloquial → canonical English name.
231
+ # This is the single source of truth for linguistic aliases.
232
+ # Market product variants (e.g. "baby potato", "sambar onion") are NOT here
233
+ # because they represent distinct inventory items — those live in _CANONICAL_MAP.
234
+ #
235
+ # Covers: Hindi, Kannada, Tamil, Telugu, Marathi, Gujarati, Bengali, common English colloquial.
236
+ ITEM_ALIASES: dict[str, list[str]] = {
237
+ # ── Vegetables ──
238
+ "tomato": ["tamatar", "tamatar", "tomatoes", "thakkali"],
239
+ "onion": ["pyaaz", "pyaz", "eerulli", "uli"],
240
+ "sambar_onion": ["sambar pyaaz", "sambar ulli", "chinna ulli"],
241
+ "potato": ["aloo", "alu", "chikka aloo", "batata", "urulai kazhangu"],
242
+ "baby_potato": ["baby aloo", "baby batata"],
243
+ "sweet_potato": ["shakarkand", "shakarkandi", "genasu"],
244
+ "carrot": ["gajar", "gajar", "carrot", "padavalakayi"],
245
+ "cucumber": ["sowthekaayi", "kheera", "kakdi"],
246
+ "brinjal": ["baingan", "vankaya", "kathirikai", "eggplant", "aubergine"],
247
+ "capsicum": ["shimla mirch", "donna mirchi", "kudaimilagai"],
248
+ "bell_pepper": ["bell pepper", "capsicum shimla", "kudaimilagai"],
249
+ "cauliflower": ["gobhi", "phool gobhi", "hogekayi"],
250
+ "broccoli": ["broccoli", "broccoli gobhi"],
251
+ "ridge_gourd": ["turai", "toraai", "peerkangai", "heerekayi"],
252
+ "bottle_gourd": ["lauki", "dudhi", "sorekayi"],
253
+ "bitter_gourd": ["karela", "pavakka", "kagalkayi"],
254
+ "snake_gourd": ["chichinda", "pudalanga", "padavalakayi"],
255
+ "pointed_gourd": ["parwal", "paraval", "tindora"],
256
+ "cluster_beans": ["guar", "guar phali", "kothavarangai"],
257
+ "french_beans": ["beans", "green beans", "hara phali", "farasbi"],
258
+ "ladys_finger": ["bhindi", "okra", "vendakkai"],
259
+ "drumstick": ["moringa", "nuggekaayi", "murungakkai"],
260
+ "beetroot": ["chukandar", "beetroot", "beet"],
261
+ "radish": ["mooli", "mooli", "mullangi", "mula"],
262
+ "raw_banana": ["kaccha kela", "vazhakkai"],
263
+ "raw_mango": ["kaccha aam", "manga", "mamidi"],
264
+ "yam": ["suran", "elephant foot yam", "senai"],
265
+ "colocasia": ["arbi", "arvi", "seppankizhangu"],
266
+ "zucchini": ["turai chini", "courgette"],
267
+ "red_cabbage": ["laal gobhi", "red cabbage"],
268
+ # ── Herbs & spices ──
269
+ "coriander": ["dhania", "cilantro", "kothambari", "kothamalli"],
270
+ "curry_leaves": ["kadi patta", "karibevu", "karivepaku", "kadi leaves"],
271
+ "mint": ["pudina", "pudina", "pudhina"],
272
+ "green_chilli": ["hari mirch", "hasi menasu", "pachai milagai", "mirchi"],
273
+ "garlic": ["lehsun", "lasun", "vellulli"],
274
+ "ginger": ["adrak", "allam", "inji"],
275
+ # ── Dairy & staples ──
276
+ "curd": ["dahi", "yogurt", "mosaru", "perugu", "thayir"],
277
+ "milk": ["doodh", "khir", "paal", "paal", "halu", "milkk"],
278
+ "paneer": ["paneer", "cottage cheese", "panir"],
279
+ "wheat flour": ["atta", "aata", "godhi hittu"],
280
+ "rice": ["chawal", "akki", "arisi"],
281
+ "lentils": ["dal", "daal", "pappu", "paruppu"],
282
+ "coconut": ["nariyal", "narkel", "tengina kayi"],
283
+ "mustard_seeds": ["rai", "sarson", "sasive"],
284
+ "turmeric": ["haldi", "arishina"],
285
+ "cumin": ["jeera", "jeerige"],
286
+ "fenugreek": ["methi", "methi seeds", "menthya"],
287
+ "black_pepper": ["kali mirch", "kali menasu", "milagu"],
288
+ # ── Fruits ──
289
+ "banana": ["kela", "balehannu", "vaazhai pazham"],
290
+ "mango": ["aam", "mavina kayi", "maanga"],
291
+ "apple": ["seb", "apple"],
292
+ "orange": ["santara", "santra", "kintoor"],
293
+ "grapes": ["angoor", "drakshi"],
294
+ "papaya": ["papita", "pappali", "babbakayi"],
295
+ "pineapple": ["ananas", "ananasina hayi"],
296
+ # ── Grains & pulses ──
297
+ "moong_dal": ["moong dal", "hesaru bele", "pasiparuppu"],
298
+ "toor_dal": ["toor dal", "tovar dal", "sambar powder dal", "thuvaram paruppu"],
299
+ "chana_dal": ["chana dal", "kadalai paruppu"],
300
+ "urad_dal": ["urad dal", "uzhunnu paruppu"],
301
+ "besan": ["gram flour", "chickpea flour", "kadle hittu"],
302
+ # ── Condiments & other ──
303
+ "sugar": ["cheeni", "sakare", "sarkarai"],
304
+ "salt": ["namak", "uppu"],
305
+ "oil": ["tel", "enne", "ennai"],
306
+ "ghee": ["ghee", "neyyi", "nei"],
307
+ "vinegar": ["sirka", "vinagiri"],
308
+ }
309
+
310
+
311
+
312
+ CANONICAL_MAP = _CANONICAL_MAP
313
+
314
+ def resolve_canonical(query: str) -> str | None:
315
+ if not query:
316
+ return None
317
+ q = query.strip().lower()
318
+
319
+ # 1. Exact match in CANONICAL_MAP
320
+ if q in CANONICAL_MAP:
321
+ return CANONICAL_MAP[q]
322
+
323
+ # 2. Exact match in ITEM_ALIASES keys
324
+ if q in ITEM_ALIASES:
325
+ return q
326
+
327
+ # 3. Substring match in CANONICAL_MAP keys
328
+ for key, val in CANONICAL_MAP.items():
329
+ if key in q or q in key:
330
+ return val
331
+
332
+ # 4. Alias lookup using ITEM_ALIASES
333
+ q_clean = re.sub(r"[^\w\s]", " ", q).strip()
334
+ q_clean = re.sub(r"\s+", " ", q_clean)
335
+ for canonical, aliases in ITEM_ALIASES.items():
336
+ if q_clean == canonical or q_clean in aliases:
337
+ return canonical
338
+ for alias in aliases:
339
+ if alias in q_clean or q_clean in alias:
340
+ return canonical
341
+
342
+ return None
343
+
344
+
345
+ def normalize_item_name(name: str) -> str:
346
+ """Normalize a user-supplied item name: clean punctuation + resolve aliases.
347
+
348
+ This is the canonical normalization for user-input and inventory matching.
349
+ Market-data normalization uses ``canonicalize_name`` instead.
350
+ """
351
+ normal = re.sub(r"[^\w\s]", " ", name.lower()).strip()
352
+ normal = re.sub(r"\s+", " ", normal)
353
+ for canonical, aliases in ITEM_ALIASES.items():
354
+ if normal == canonical or normal in aliases:
355
+ return canonical
356
+ return normal
357
+
358
+
359
+ def canonicalize_name(raw_name: str) -> tuple[str, str, list[str]]:
360
+ cleaned = _clean_name(raw_name)
361
+ is_combo = _detect_combo(raw_name)
362
+ components: list[str] = []
363
+
364
+ if is_combo:
365
+ components = _extract_combo_components(cleaned)
366
+ slug_parts = [c for c in components if c]
367
+ if slug_parts:
368
+ slug = "combo_" + "_".join(slug_parts[:5])
369
+ else:
370
+ slug = "combo_" + cleaned.replace(" ", "_")[:40]
371
+ return slug, "", components
372
+
373
+ lowered = cleaned.lower()
374
+ canonical = _CANONICAL_MAP.get(lowered, "")
375
+ if not canonical:
376
+ for key, val in _CANONICAL_MAP.items():
377
+ if key in lowered or lowered in key:
378
+ canonical = val
379
+ break
380
+ if not canonical:
381
+ canonical = lowered.replace(" ", "_")[:40]
382
+
383
+ variety = ""
384
+ if raw_name != cleaned:
385
+ paren = re.search(r"\(([^)]+)\)", raw_name)
386
+ if paren:
387
+ variety = paren.group(1)
388
+
389
+ return canonical, variety, []
390
+
391
+
392
+ def _clean_name(name: str) -> str:
393
+ cleaned = re.sub(r"\s*-\s*.*$", "", name)
394
+ cleaned = re.sub(r"\s*\([^)]*\)\s*", " ", cleaned).strip()
395
+ # Strip apostrophes so "Lady's Finger" normalises to "ladys finger" (matches canonical map)
396
+ cleaned = cleaned.replace("'", "")
397
+ cleaned = re.sub(r"\s+", " ", cleaned)
398
+ prefixes = ("nectr ", "pluckk ozone washed ", "pluckk ", "freshcon cooked ", "urban harvest ")
399
+ for prefix in prefixes:
400
+ if cleaned.lower().startswith(prefix):
401
+ cleaned = cleaned[len(prefix):]
402
+ break
403
+ cleaned = re.sub(r"\s+\(chemical free\)", "", cleaned, flags=re.IGNORECASE)
404
+ cleaned = re.sub(r"\s+chemical free", "", cleaned, flags=re.IGNORECASE)
405
+ cleaned = re.sub(r"\s+protected cultivation", "", cleaned, flags=re.IGNORECASE)
406
+ return cleaned.strip()
407
+
408
+
409
+ def _detect_combo(name: str) -> bool:
410
+ lowered = name.lower()
411
+ if "combo" in lowered:
412
+ return True
413
+ if " & " in lowered or "&" in lowered:
414
+ parts = [p.strip() for p in lowered.split("&") if p.strip()]
415
+ if len(parts) >= 2:
416
+ return True
417
+ if "herbs mix" in lowered:
418
+ return True
419
+ if "sambar veg" in lowered:
420
+ return True
421
+ return False
422
+
423
+
424
+ def _extract_combo_components(cleaned_name: str) -> list[str]:
425
+ lowered = cleaned_name.lower()
426
+ if "herbs mix" in lowered:
427
+ return ["curry_leaves", "coriander", "mint"]
428
+ if "sambar veg" in lowered:
429
+ return ["drumstick", "radish", "cluster_beans", "ladys_finger"]
430
+
431
+ parts = re.split(r"[,&]|, and | and ", lowered)
432
+ components: list[str] = []
433
+ for part in parts:
434
+ part = part.strip()
435
+ if not part:
436
+ continue
437
+ clean_part = _clean_name(part)
438
+ canonical = _CANONICAL_MAP.get(clean_part, "")
439
+ if not canonical:
440
+ for key, val in _CANONICAL_MAP.items():
441
+ if key in clean_part:
442
+ canonical = val
443
+ break
444
+ if not canonical:
445
+ canonical = clean_part.replace(" ", "_")[:30]
446
+ components.append(canonical)
447
+ return components
shopstack/market/schema.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any
5
+
6
+
7
+ @dataclass
8
+ class NormalizedMarketRecord:
9
+ source: str
10
+ source_category: str
11
+ raw_name: str
12
+ canonical_name: str
13
+ description: str
14
+ raw_size: str
15
+ normalized_quantity: float | None
16
+ normalized_unit: str | None
17
+ package_count: int
18
+ is_combo: bool
19
+ is_weight_based: bool
20
+ is_piece_based: bool
21
+ is_size_class: bool
22
+ size_class: str
23
+ price_inr: float
24
+ mrp_inr: float
25
+ discount_percent_displayed: float
26
+ discount_amount_inr: float
27
+ computed_discount_percent: float
28
+ availability: str
29
+ is_available: bool
30
+ tag: str
31
+ is_ad: bool
32
+ is_upgrade: bool
33
+ card_index: int
34
+ delivery_time: str
35
+ captured_at: str
36
+ snapshot_id: str
37
+ price_per_kg: float | None
38
+ price_per_100g: float | None
39
+ price_per_piece: float | None
40
+ normalization_warnings: list[str] = field(default_factory=list)
41
+ component_names: list[str] = field(default_factory=list)
42
+ variety: str = ""
43
+ brand: str = ""
44
+
45
+ def to_dict(self) -> dict[str, Any]:
46
+ return {
47
+ "source": self.source,
48
+ "source_category": self.source_category,
49
+ "raw_name": self.raw_name,
50
+ "canonical_name": self.canonical_name,
51
+ "description": self.description,
52
+ "raw_size": self.raw_size,
53
+ "normalized_quantity": self.normalized_quantity,
54
+ "normalized_unit": self.normalized_unit,
55
+ "package_count": self.package_count,
56
+ "is_combo": self.is_combo,
57
+ "is_weight_based": self.is_weight_based,
58
+ "is_piece_based": self.is_piece_based,
59
+ "is_size_class": self.is_size_class,
60
+ "size_class": self.size_class,
61
+ "price_inr": self.price_inr,
62
+ "mrp_inr": self.mrp_inr,
63
+ "discount_percent_displayed": self.discount_percent_displayed,
64
+ "discount_amount_inr": self.discount_amount_inr,
65
+ "computed_discount_percent": self.computed_discount_percent,
66
+ "availability": self.availability,
67
+ "is_available": self.is_available,
68
+ "tag": self.tag,
69
+ "is_ad": self.is_ad,
70
+ "is_upgrade": self.is_upgrade,
71
+ "card_index": self.card_index,
72
+ "delivery_time": self.delivery_time,
73
+ "captured_at": self.captured_at,
74
+ "snapshot_id": self.snapshot_id,
75
+ "price_per_kg": self.price_per_kg,
76
+ "price_per_100g": self.price_per_100g,
77
+ "price_per_piece": self.price_per_piece,
78
+ "normalization_warnings": self.normalization_warnings,
79
+ "component_names": self.component_names,
80
+ "variety": self.variety,
81
+ "brand": self.brand,
82
+ }
83
+
84
+
85
+ @dataclass
86
+ class MarketSnapshot:
87
+ snapshot_id: str
88
+ source: str
89
+ source_category: str
90
+ captured_at: str
91
+ raw_records: list[dict[str, Any]]
92
+ normalized_records: list[NormalizedMarketRecord]
93
+ analytics: dict[str, Any] = field(default_factory=dict)
shopstack/market/sources/__init__.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Market source abstraction — adapters and repository for market data sources.
2
+
3
+ This package defines the MarketSourceAdapter protocol that every market data
4
+ source (Swiggy, Blinkit, Zepto, DMart, etc.) must implement, plus a
5
+ MarketSnapshotRepository for caching and storing snapshots.
6
+
7
+ New sources register via SourceRegistry::
8
+
9
+ from shopstack.market.sources import SourceRegistry, build_registry
10
+
11
+ registry = build_registry() # registers all available sources
12
+ snapshot = registry.load("swiggy")
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ from pathlib import Path
19
+
20
+ from shopstack.market.sources._adapter import MarketSourceAdapter, MarketSourceError
21
+ from shopstack.market.sources._registry import SourceRegistry
22
+ from shopstack.market.sources._repository import MarketSnapshotRepository, snapshot_freshness
23
+ from shopstack.market.sources._swiggy_adapter import SwiggyAdapter
24
+ from shopstack.market.sources._blinkit_adapter import BlinkitAdapter
25
+ from shopstack.market.sources._zepto_adapter import ZeptoAdapter
26
+ from shopstack.market.sources._dmart_adapter import DmartAdapter
27
+ from shopstack.market.sources._comparison import (
28
+ CrossSourcePrice,
29
+ compare_across_sources,
30
+ format_cross_source_html,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ def build_registry(repository: MarketSnapshotRepository | None = None, data_dir: Path | None = None) -> SourceRegistry:
37
+ """Construct a fully-populated SourceRegistry with all available market sources.
38
+
39
+ Each adapter is constructed inside a try/except so that any adapter init failure
40
+ doesn't prevent the registry from being built — failures simply log a warning.
41
+ Missing data files are handled gracefully at load_snapshot() time.
42
+ """
43
+ registry = SourceRegistry(repository=repository)
44
+
45
+ sources: list[tuple[str, MarketSourceAdapter]] = []
46
+
47
+ for adapter_cls, source_id in [
48
+ (SwiggyAdapter, "swiggy"),
49
+ (BlinkitAdapter, "blinkit"),
50
+ (ZeptoAdapter, "zepto"),
51
+ (DmartAdapter, "dmart"),
52
+ ]:
53
+ try:
54
+ adapter = adapter_cls(data_dir=data_dir)
55
+ sources.append((source_id, adapter))
56
+ except Exception as exc:
57
+ logger.warning("Failed to create adapter %s: %s", source_id, exc)
58
+
59
+ for source_id, adapter in sources:
60
+ registry.register(source_id, adapter)
61
+
62
+ return registry
63
+
64
+
65
+ __all__ = [
66
+ "MarketSourceAdapter",
67
+ "MarketSourceError",
68
+ "MarketSnapshotRepository",
69
+ "SourceRegistry",
70
+ "build_registry",
71
+ "SwiggyAdapter",
72
+ "BlinkitAdapter",
73
+ "ZeptoAdapter",
74
+ "DmartAdapter",
75
+ "CrossSourcePrice",
76
+ "compare_across_sources",
77
+ "format_cross_source_html",
78
+ "snapshot_freshness",
79
+ ]
shopstack/market/sources/_adapter.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MarketSourceAdapter — protocol for pluggable market data sources.
2
+
3
+ Every source (Swiggy, Blinkit, Zepto, DMart, etc.) implements this protocol
4
+ so the decision engine and basket optimizer can work with any source uniformly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Protocol, runtime_checkable
12
+
13
+ from shopstack.market.schema import MarketSnapshot
14
+
15
+
16
+ class MarketSourceError(Exception):
17
+ pass
18
+
19
+
20
+ @runtime_checkable
21
+ class MarketSourceAdapter(Protocol):
22
+ """Protocol each market source must satisfy.
23
+
24
+ A market source is anything that can produce a MarketSnapshot:
25
+ an API client, a CSV file reader, a web scraper, etc.
26
+ """
27
+
28
+ source_id: str
29
+ source_category: str
30
+
31
+ def load_snapshot(self) -> MarketSnapshot:
32
+ """Load the latest snapshot from this source.
33
+
34
+ Returns a fully normalized MarketSnapshot ready for analytics.
35
+ Raises MarketSourceError on failure.
36
+ """
37
+ ...
38
+
39
+ def freshness(self, snapshot: MarketSnapshot) -> dict[str, Any]:
40
+ """Return freshness metadata for a snapshot from this source.
41
+
42
+ Returns dict with keys:
43
+ - age_days: int
44
+ - is_stale: bool
45
+ - label: str (human-readable freshness description)
46
+ - captured_at: str
47
+ """
48
+ ...
49
+
50
+ def available_canonical_names(self, snapshot: MarketSnapshot) -> set[str]:
51
+ """Return the set of canonical names available in this snapshot."""
52
+ ...
53
+
54
+
55
+ @dataclass
56
+ class SourceMetadata:
57
+ """Persisted metadata about a market source and its snapshots."""
58
+
59
+ source_id: str
60
+ source_category: str
61
+ last_snapshot_id: str = ""
62
+ last_captured_at: str = ""
63
+ snapshot_count: int = 0
64
+ total_records: int = 0
65
+ created_at: str = ""
66
+ updated_at: str = ""
67
+
68
+ @classmethod
69
+ def now(cls) -> str:
70
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
shopstack/market/sources/_blinkit_adapter.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Blinkit market source adapter — thin config over shared JSON adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from shopstack.market.schema import MarketSnapshot
10
+ from shopstack.market.sources._adapter import MarketSourceAdapter
11
+ from shopstack.market.sources._json_adapter import (
12
+ JsonSourceConfig,
13
+ _normalize_record_shared,
14
+ _load_raw_json,
15
+ snapshot_freshness,
16
+ load_snapshot as _shared_load_snapshot,
17
+ )
18
+
19
+ SOURCE_ID = "blinkit"
20
+ SOURCE_CATEGORY = "fresh_vegetables"
21
+ DEFAULT_SNAPSHOT_ID = f"{SOURCE_ID}_{SOURCE_CATEGORY}_2026-06-06"
22
+ DEFAULT_CAPTURED_AT = "2026-06-06"
23
+
24
+ CONFIG = JsonSourceConfig(
25
+ source_id=SOURCE_ID,
26
+ source_category=SOURCE_CATEGORY,
27
+ file_glob="blinkit_fresh_vegetables_*.json",
28
+ name_field="product_name",
29
+ price_field="offer_price",
30
+ original_price_field="mrp",
31
+ )
32
+
33
+
34
+ def load_raw(data_dir: Path | None = None):
35
+ return _load_raw_json(CONFIG.file_glob, data_dir)
36
+
37
+
38
+ def load_snapshot(
39
+ data_dir: Path | None = None,
40
+ snapshot_id: str | None = None,
41
+ captured_at: str | None = None,
42
+ ):
43
+ return _shared_load_snapshot(CONFIG, data_dir=data_dir, snapshot_id=snapshot_id, captured_at=captured_at)
44
+
45
+
46
+ def normalize_record(
47
+ raw: dict[str, Any],
48
+ snapshot_id: str = DEFAULT_SNAPSHOT_ID,
49
+ captured_at: str = DEFAULT_CAPTURED_AT,
50
+ ) -> Any:
51
+ from dataclasses import replace
52
+ cfg = replace(CONFIG, captured_at=captured_at)
53
+ return _normalize_record_shared(raw, cfg)
54
+
55
+
56
+ class BlinkitAdapter(MarketSourceAdapter):
57
+ source_id: str = SOURCE_ID
58
+ source_category: str = SOURCE_CATEGORY
59
+
60
+ def __init__(self, data_dir: Path | None = None):
61
+ self._data_dir = data_dir
62
+
63
+ def load_snapshot(self) -> MarketSnapshot:
64
+ return _shared_load_snapshot(CONFIG, data_dir=self._data_dir)
65
+
66
+ def freshness(self, snapshot: MarketSnapshot) -> dict[str, Any]:
67
+ return snapshot_freshness(snapshot, today=date.today())
68
+
69
+ def available_canonical_names(self, snapshot: MarketSnapshot) -> set[str]:
70
+ from shopstack.market.analytics import available_canonical_names as _available
71
+ return _available(snapshot)
72
+
73
+
74
+ __all__ = [
75
+ "BlinkitAdapter",
76
+ "CONFIG",
77
+ "DEFAULT_CAPTURED_AT",
78
+ "DEFAULT_SNAPSHOT_ID",
79
+ "SOURCE_ID",
80
+ "load_raw",
81
+ "load_snapshot",
82
+ "normalize_record",
83
+ "snapshot_freshness",
84
+ ]
shopstack/market/sources/_comparison.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from html import escape
5
+ from typing import Any
6
+
7
+ from shopstack.market.sources._registry import SourceRegistry
8
+
9
+
10
+ @dataclass
11
+ class CrossSourcePrice:
12
+ canonical_name: str
13
+ prices: dict[str, float]
14
+ best_source: str
15
+ savings_pct: float
16
+
17
+
18
+ def _best_weight_option(records: list[Any]) -> float | None:
19
+ candidates = [
20
+ r for r in records
21
+ if not r.is_combo and r.is_available and r.price_per_kg is not None
22
+ ]
23
+ if not candidates:
24
+ candidates = [
25
+ r for r in records
26
+ if not r.is_combo and r.is_available
27
+ ]
28
+ if not candidates:
29
+ return None
30
+ return min(r.price_inr for r in candidates)
31
+ return min(r.price_per_kg for r in candidates)
32
+
33
+
34
+ def compare_across_sources(
35
+ registry: SourceRegistry,
36
+ canonical_name: str,
37
+ ) -> CrossSourcePrice | None:
38
+ snapshots = registry.all_snapshots()
39
+ if not snapshots:
40
+ return None
41
+
42
+ prices: dict[str, float] = {}
43
+ for source_id, snap in snapshots.items():
44
+ records = [
45
+ r for r in snap.normalized_records
46
+ if r.canonical_name == canonical_name
47
+ ]
48
+ if not records:
49
+ continue
50
+ best = _best_weight_option(records)
51
+ if best is not None:
52
+ prices[source_id] = best
53
+
54
+ if len(prices) < 2:
55
+ return None
56
+
57
+ best_source = min(prices, key=lambda s: prices[s])
58
+ worst_source = max(prices, key=lambda s: prices[s])
59
+ best_price = prices[best_source]
60
+ worst_price = prices[worst_source]
61
+ savings_pct = round((worst_price - best_price) / worst_price * 100) if worst_price > 0 else 0
62
+
63
+ return CrossSourcePrice(
64
+ canonical_name=canonical_name,
65
+ prices=prices,
66
+ best_source=best_source,
67
+ savings_pct=savings_pct,
68
+ )
69
+
70
+
71
+ def format_cross_source_html(comparisons: list[CrossSourcePrice]) -> str:
72
+ if not comparisons:
73
+ return ""
74
+
75
+ source_ids: set[str] = set()
76
+ for c in comparisons:
77
+ source_ids.update(c.prices.keys())
78
+ sorted_sources = sorted(source_ids)
79
+
80
+ header_cells = "".join(
81
+ f"<th style='text-align:right;padding:4px;'>{escape(s.title())}</th>"
82
+ for s in sorted_sources
83
+ )
84
+
85
+ rows: list[str] = []
86
+ for c in comparisons:
87
+ name_label = escape(c.canonical_name.replace("_", " ").title())
88
+ price_cells = ""
89
+ for sid in sorted_sources:
90
+ price = c.prices.get(sid)
91
+ if price is None:
92
+ price_cells += "<td style='text-align:right;padding:4px;color:var(--text-dim);'>--</td>"
93
+ elif sid == c.best_source:
94
+ price_cells += f"<td style='text-align:right;padding:4px;color:var(--green);font-weight:600;'>&#8377;{price:.0f}</td>"
95
+ else:
96
+ price_cells += f"<td style='text-align:right;padding:4px;'>&#8377;{price:.0f}</td>"
97
+
98
+ savings_label = f"save {c.savings_pct}%" if c.savings_pct > 0 else ""
99
+ rows.append(
100
+ f"<tr>"
101
+ f"<td style='padding:4px;'><strong>{name_label}</strong></td>"
102
+ f"{price_cells}"
103
+ f"<td style='padding:4px;color:var(--green);'>{savings_label}</td>"
104
+ f"</tr>"
105
+ )
106
+
107
+ return (
108
+ f"<div class='home-card' style='text-align:left;margin-bottom:12px;'>"
109
+ f"<h3>Multi-Source Price Comparison</h3>"
110
+ f"<table style='width:100%;border-collapse:collapse;font-size: 0.75rem;'>"
111
+ f"<thead><tr style='border-bottom:2px solid var(--border);'>"
112
+ f"<th style='text-align:left;padding:4px;'>Item</th>"
113
+ f"{header_cells}"
114
+ f"<th style='text-align:left;padding:4px;'>Savings</th>"
115
+ f"</tr></thead>"
116
+ f"<tbody>{''.join(rows)}</tbody>"
117
+ f"</table></div>"
118
+ )
119
+
120
+
121
+ __all__ = ["CrossSourcePrice", "compare_across_sources", "format_cross_source_html"]
shopstack/market/sources/_dmart_adapter.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DMart market source adapter — thin config over shared JSON adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from shopstack.market.schema import MarketSnapshot
10
+ from shopstack.market.sources._adapter import MarketSourceAdapter
11
+ from shopstack.market.sources._json_adapter import (
12
+ JsonSourceConfig,
13
+ _normalize_record_shared,
14
+ _load_raw_json,
15
+ snapshot_freshness,
16
+ load_snapshot as _shared_load_snapshot,
17
+ )
18
+
19
+ SOURCE_ID = "dmart"
20
+ SOURCE_CATEGORY = "fresh_vegetables"
21
+ DEFAULT_SNAPSHOT_ID = f"{SOURCE_ID}_{SOURCE_CATEGORY}_2026-06-06"
22
+ DEFAULT_CAPTURED_AT = "2026-06-06"
23
+
24
+ CONFIG = JsonSourceConfig(
25
+ source_id=SOURCE_ID,
26
+ source_category=SOURCE_CATEGORY,
27
+ file_glob="dmart_fresh_vegetables_*.json",
28
+ name_field="product",
29
+ price_field="current_price",
30
+ original_price_field="listed_price",
31
+ )
32
+
33
+
34
+ def load_raw(data_dir: Path | None = None):
35
+ return _load_raw_json(CONFIG.file_glob, data_dir)
36
+
37
+
38
+ def load_snapshot(
39
+ data_dir: Path | None = None,
40
+ snapshot_id: str | None = None,
41
+ captured_at: str | None = None,
42
+ ):
43
+ return _shared_load_snapshot(CONFIG, data_dir=data_dir, snapshot_id=snapshot_id, captured_at=captured_at)
44
+
45
+
46
+ def normalize_record(
47
+ raw: dict[str, Any],
48
+ snapshot_id: str = DEFAULT_SNAPSHOT_ID,
49
+ captured_at: str = DEFAULT_CAPTURED_AT,
50
+ ) -> Any:
51
+ from dataclasses import replace
52
+ cfg = replace(CONFIG, captured_at=captured_at)
53
+ return _normalize_record_shared(raw, cfg)
54
+
55
+
56
+ class DmartAdapter(MarketSourceAdapter):
57
+ source_id: str = SOURCE_ID
58
+ source_category: str = SOURCE_CATEGORY
59
+
60
+ def __init__(self, data_dir: Path | None = None):
61
+ self._data_dir = data_dir
62
+
63
+ def load_snapshot(self) -> MarketSnapshot:
64
+ return _shared_load_snapshot(CONFIG, data_dir=self._data_dir)
65
+
66
+ def freshness(self, snapshot: MarketSnapshot) -> dict[str, Any]:
67
+ return snapshot_freshness(snapshot, today=date.today())
68
+
69
+ def available_canonical_names(self, snapshot: MarketSnapshot) -> set[str]:
70
+ from shopstack.market.analytics import available_canonical_names as _available
71
+ return _available(snapshot)
72
+
73
+
74
+ __all__ = [
75
+ "DmartAdapter",
76
+ "CONFIG",
77
+ "DEFAULT_CAPTURED_AT",
78
+ "DEFAULT_SNAPSHOT_ID",
79
+ "SOURCE_ID",
80
+ "load_raw",
81
+ "load_snapshot",
82
+ "normalize_record",
83
+ "snapshot_freshness",
84
+ ]
shopstack/market/sources/_json_adapter.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared base for JSON-file-backed market source adapters.
2
+
3
+ Blinkit, Zepto, and DMart adapters are structurally identical except for:
4
+ - source ID
5
+ - JSON file glob pattern
6
+ - raw-record field names for product name and price
7
+ - price calculation logic
8
+
9
+ This module extracts the common code so each adapter is a thin config.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ from dataclasses import dataclass
17
+ from datetime import date
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from shopstack.market.normalization import (
22
+ SizeParseResult,
23
+ canonicalize_name,
24
+ compute_unit_prices,
25
+ parse_size,
26
+ )
27
+ from shopstack.market.schema import MarketSnapshot, NormalizedMarketRecord
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ _DEFAULT_DATA_DIR = Path(__file__).resolve().parents[3] / "data"
32
+ FRESHNESS_WARNING_DAYS = 1
33
+
34
+
35
+ # ── Shared helpers ─────────────────────────────────────────────────────
36
+
37
+
38
+ def _coerce_float(val: Any, default: float = 0.0) -> float:
39
+ if val is None or val == "":
40
+ return default
41
+ try:
42
+ return float(val)
43
+ except (ValueError, TypeError):
44
+ return default
45
+
46
+
47
+ def _coerce_int(val: Any, default: int = 0) -> int:
48
+ if val is None or val == "":
49
+ return default
50
+ try:
51
+ return int(val)
52
+ except (ValueError, TypeError):
53
+ return default
54
+
55
+
56
+ def _find_json(glob_pattern: str, data_dir: Path | None = None) -> Path:
57
+ d = data_dir or _DEFAULT_DATA_DIR
58
+ candidates = sorted(d.glob(glob_pattern))
59
+ if not candidates:
60
+ raise FileNotFoundError(f"No {glob_pattern} found in {d}")
61
+ return candidates[-1]
62
+
63
+
64
+ def _load_raw_json(glob_pattern: str, data_dir: Path | None = None) -> list[dict[str, Any]]:
65
+ fp = _find_json(glob_pattern, data_dir)
66
+ with open(fp, encoding="utf-8") as f:
67
+ return json.load(f)
68
+
69
+
70
+ # ── Per-source config ──────────────────────────────────────────────────
71
+
72
+
73
+ @dataclass
74
+ class JsonSourceConfig:
75
+ source_id: str
76
+ source_category: str
77
+ file_glob: str
78
+ name_field: str
79
+ price_field: str
80
+ original_price_field: str
81
+ captured_at: str = "2026-06-06"
82
+
83
+ @property
84
+ def snapshot_id(self) -> str:
85
+ return f"{self.source_id}_{self.source_category}_{self.captured_at}"
86
+
87
+ def extract_price(self, raw: dict[str, Any]) -> tuple[float, float]:
88
+ """Return (price, mrp) from a raw record. Override for custom logic."""
89
+ price = _coerce_float(raw.get(self.price_field))
90
+ mrp = _coerce_float(raw.get(self.original_price_field))
91
+ if price <= 0:
92
+ price = mrp
93
+ if mrp <= 0 or mrp == price:
94
+ mrp = price
95
+ return price, mrp
96
+
97
+
98
+ # ── Shared normalization ───────────────────────────────────────────────
99
+
100
+
101
+ def _normalize_record_shared(
102
+ raw: dict[str, Any],
103
+ config: JsonSourceConfig,
104
+ ) -> NormalizedMarketRecord:
105
+ raw_name = str(raw.get(config.name_field, "")).strip()
106
+ raw_size = str(raw.get("size", "")).strip()
107
+
108
+ canonical, variety, components = canonicalize_name(raw_name)
109
+ size_result: SizeParseResult = parse_size(raw_size)
110
+
111
+ is_combo = len(components) > 1 or size_result.is_combo
112
+ availability = str(raw.get("availability", "")).strip()
113
+ is_available = availability.lower() == "available"
114
+
115
+ price, mrp = config.extract_price(raw)
116
+
117
+ unit_prices = compute_unit_prices(
118
+ price=price,
119
+ quantity=size_result.normalized_quantity,
120
+ unit=size_result.normalized_unit,
121
+ is_weight_based=size_result.is_weight_based,
122
+ is_piece_based=size_result.is_piece_based,
123
+ )
124
+
125
+ discount_amount = mrp - price if mrp > price else 0.0
126
+ computed_discount = round(discount_amount / mrp * 100, 1) if mrp > 0 else 0.0
127
+
128
+ warnings: list[str] = list(size_result.warnings or [])
129
+
130
+ return NormalizedMarketRecord(
131
+ source=config.source_id,
132
+ source_category=config.source_category,
133
+ raw_name=raw_name,
134
+ canonical_name=canonical,
135
+ description=str(raw.get("description", "")).strip(),
136
+ raw_size=raw_size,
137
+ normalized_quantity=size_result.normalized_quantity,
138
+ normalized_unit=size_result.normalized_unit,
139
+ package_count=size_result.package_count,
140
+ is_combo=is_combo,
141
+ is_weight_based=size_result.is_weight_based,
142
+ is_piece_based=size_result.is_piece_based,
143
+ is_size_class=size_result.is_size_class,
144
+ size_class=size_result.size_class,
145
+ price_inr=price,
146
+ mrp_inr=mrp,
147
+ discount_percent_displayed=_coerce_float(raw.get("discount_percent")),
148
+ discount_amount_inr=discount_amount,
149
+ computed_discount_percent=computed_discount,
150
+ availability=availability,
151
+ is_available=is_available,
152
+ tag=str(raw.get("tag", "")).strip(),
153
+ is_ad=str(raw.get("tag", "")).strip().lower() == "ad",
154
+ is_upgrade=str(raw.get("tag", "")).strip().lower() == "upgrade",
155
+ card_index=_coerce_int(raw.get("card_index")),
156
+ delivery_time=str(raw.get("delivery_time", "")).strip(),
157
+ captured_at=config.captured_at,
158
+ snapshot_id=config.snapshot_id,
159
+ price_per_kg=unit_prices["price_per_kg"],
160
+ price_per_100g=unit_prices["price_per_100g"],
161
+ price_per_piece=unit_prices["price_per_piece"],
162
+ normalization_warnings=warnings,
163
+ component_names=components or [],
164
+ variety=variety,
165
+ brand=str(raw.get("brand", "")).strip(),
166
+ )
167
+
168
+
169
+ def load_snapshot(
170
+ config: JsonSourceConfig,
171
+ data_dir: Path | None = None,
172
+ snapshot_id: str | None = None,
173
+ captured_at: str | None = None,
174
+ ) -> MarketSnapshot:
175
+ raw_records = _load_raw_json(config.file_glob, data_dir)
176
+ sid = snapshot_id or config.snapshot_id
177
+ cat = captured_at or config.captured_at
178
+
179
+ from dataclasses import replace
180
+ cfg = replace(config, captured_at=cat)
181
+
182
+ normalized = [_normalize_record_shared(r, cfg) for r in raw_records]
183
+
184
+ return MarketSnapshot(
185
+ snapshot_id=sid,
186
+ source=config.source_id,
187
+ source_category=config.source_category,
188
+ captured_at=cat,
189
+ raw_records=raw_records,
190
+ normalized_records=normalized,
191
+ )
192
+
193
+
194
+ def snapshot_freshness(snapshot: MarketSnapshot, today: date | None = None) -> dict[str, Any]:
195
+ current = today or date.today()
196
+ try:
197
+ captured = date.fromisoformat(snapshot.captured_at[:10])
198
+ except (ValueError, TypeError):
199
+ return {
200
+ "captured_at": snapshot.captured_at,
201
+ "age_days": None,
202
+ "is_stale": True,
203
+ "label": f"Snapshot date unclear: {snapshot.captured_at or 'unknown'}",
204
+ }
205
+
206
+ age_days = (current - captured).days
207
+ if age_days <= 0:
208
+ label = f"Captured today ({snapshot.captured_at})"
209
+ elif age_days == 1:
210
+ label = f"Captured yesterday ({snapshot.captured_at})"
211
+ else:
212
+ label = f"Captured {age_days} days ago ({snapshot.captured_at})"
213
+ return {
214
+ "captured_at": snapshot.captured_at,
215
+ "age_days": age_days,
216
+ "is_stale": age_days > FRESHNESS_WARNING_DAYS,
217
+ "label": label,
218
+ }
shopstack/market/sources/_registry.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SourceRegistry — pluggable registry of market source adapters.
2
+
3
+ New sources (Blinkit, Zepto, DMart, etc.) are registered once at startup.
4
+ All service-level code resolves market data through this registry, never
5
+ by importing a specific source directly.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import Any
12
+
13
+ from shopstack.market.schema import MarketSnapshot
14
+ from shopstack.market.sources._adapter import MarketSourceAdapter
15
+ from shopstack.market.sources._repository import MarketSnapshotRepository
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class SourceRegistry:
21
+ """Registry of available market source adapters.
22
+
23
+ Usage::
24
+
25
+ registry = SourceRegistry()
26
+ registry.register("swiggy", SwiggyAdapter())
27
+
28
+ # Load from a specific source
29
+ snap = registry.load("swiggy")
30
+
31
+ # Or discover sources for a category
32
+ for source in registry.for_category("fresh_vegetables"):
33
+ snap = registry.load(source.source_id)
34
+
35
+ """
36
+
37
+ def __init__(self, repository: MarketSnapshotRepository | None = None):
38
+ self._adapters: dict[str, MarketSourceAdapter] = {}
39
+ self._repository = repository or MarketSnapshotRepository()
40
+
41
+ def register(self, source_id: str, adapter: MarketSourceAdapter) -> None:
42
+ if not isinstance(adapter, MarketSourceAdapter):
43
+ logger.warning(
44
+ "Adapter for %s does not satisfy MarketSourceAdapter protocol",
45
+ source_id,
46
+ )
47
+ self._adapters[source_id] = adapter
48
+ logger.info("Registered market source: %s (%s)", source_id, adapter.source_category)
49
+
50
+ def registered(self) -> list[str]:
51
+ return list(self._adapters.keys())
52
+
53
+ def get(self, source_id: str) -> MarketSourceAdapter:
54
+ adapter = self._adapters.get(source_id)
55
+ if adapter is None:
56
+ raise KeyError(f"Unknown market source: {source_id}. Registered: {list(self._adapters)}")
57
+ return adapter
58
+
59
+ def load(self, source_id: str) -> MarketSnapshot:
60
+ adapter = self.get(source_id)
61
+ snapshot = adapter.load_snapshot()
62
+ self._repository.store(snapshot)
63
+ return snapshot
64
+
65
+ def load_all(self, timeout_per_source: float = 5.0) -> dict[str, MarketSnapshot]:
66
+ """Load snapshots for all registered adapters actively.
67
+
68
+ Iterates registered adapters, loads each, and returns a dictionary
69
+ mapping source_id to MarketSnapshot. Failures are logged and skipped.
70
+ """
71
+ import concurrent.futures
72
+ loaded: dict[str, MarketSnapshot] = {}
73
+ for source_id in list(self._adapters.keys()):
74
+ try:
75
+ # Only load if not already in the repository/cache to avoid duplicate overhead
76
+ snapshot = self._repository.latest(source_id)
77
+ if snapshot is not None:
78
+ loaded[source_id] = snapshot
79
+ continue
80
+
81
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
82
+ future = executor.submit(self.load, source_id)
83
+ snapshot = future.result(timeout=timeout_per_source)
84
+ if snapshot is not None:
85
+ loaded[source_id] = snapshot
86
+ except Exception as exc:
87
+ logger.warning("Failed to actively load market source %s: %s", source_id, exc)
88
+ continue
89
+ return loaded
90
+
91
+ def latest(self, source: str) -> MarketSnapshot | None:
92
+ return self._repository.latest(source)
93
+
94
+ def all_sources_latest(self) -> dict[str, MarketSnapshot]:
95
+ return self.all_snapshots()
96
+
97
+ def for_category(self, category: str) -> list[MarketSourceAdapter]:
98
+ return [a for a in self._adapters.values() if a.source_category == category]
99
+
100
+ def all_snapshots(self) -> dict[str, MarketSnapshot]:
101
+ result: dict[str, MarketSnapshot] = {}
102
+ for sid in self._adapters:
103
+ snap = self._repository.latest(sid)
104
+ if snap is not None:
105
+ result[sid] = snap
106
+ return result
107
+
108
+ def freshness_of(self, source_id: str) -> dict[str, Any]:
109
+ snap = self._repository.latest(source_id)
110
+ if snap is None:
111
+ return {"source_id": source_id, "is_stale": True, "label": "No snapshot loaded"}
112
+ adapter = self.get(source_id)
113
+ return adapter.freshness(snap)
114
+
115
+ @property
116
+ def repository(self) -> MarketSnapshotRepository:
117
+ return self._repository
shopstack/market/sources/_repository.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MarketSnapshotRepository — caching and lifecycle for market snapshots.
2
+
3
+ Stores snapshots so the same data isn't re-parsed on every dashboard load,
4
+ and tracks freshness metadata so consumers know how current the data is.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import Any
11
+
12
+ from shopstack.market.schema import MarketSnapshot
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class MarketSnapshotRepository:
18
+ """In-memory snapshot cache with optional persistence.
19
+
20
+ The default instance is process-scoped; pass a database to enable
21
+ cross-session persistence.
22
+ """
23
+
24
+ def __init__(self, db=None):
25
+ self._cache: dict[str, MarketSnapshot] = {}
26
+ self._db: Any | None = db
27
+
28
+ def store(self, snapshot: MarketSnapshot) -> None:
29
+ self._cache[snapshot.snapshot_id] = snapshot
30
+ if self._db is not None:
31
+ self._persist(snapshot)
32
+
33
+ def get(self, snapshot_id: str) -> MarketSnapshot | None:
34
+ if not snapshot_id:
35
+ return None
36
+ if snapshot_id in self._cache:
37
+ return self._cache[snapshot_id]
38
+ if self._db is None:
39
+ return None
40
+ return self._load_snapshot_from_db(snapshot_id)
41
+
42
+ def latest(self, source: str = "") -> MarketSnapshot | None:
43
+ candidates = list(self._cache.values())
44
+
45
+ if not source:
46
+ if candidates:
47
+ return max(candidates, key=lambda s: s.captured_at)
48
+ return self._latest_snapshot_any()
49
+
50
+ source = source.strip()
51
+ candidates = [s for s in candidates if s.source == source]
52
+ if candidates:
53
+ return max(candidates, key=lambda s: s.captured_at)
54
+
55
+ return self._latest_snapshot_from_db(source)
56
+
57
+ def list(self, source: str = "") -> list[MarketSnapshot]:
58
+ snapshots = list(self._cache.values())
59
+ if source:
60
+ snapshots = [s for s in snapshots if s.source == source]
61
+
62
+ if self._db is None:
63
+ snapshots.sort(key=lambda s: s.captured_at, reverse=True)
64
+ return snapshots
65
+
66
+ db_snapshots = self._list_snapshots_from_db(source=source)
67
+ seen: set[str] = set()
68
+ merged: list[MarketSnapshot] = []
69
+ for snap in db_snapshots + snapshots:
70
+ if snap.snapshot_id in seen:
71
+ continue
72
+ seen.add(snap.snapshot_id)
73
+ merged.append(snap)
74
+
75
+ merged.sort(key=lambda s: s.captured_at, reverse=True)
76
+ return merged
77
+
78
+ def clear(self, source: str = "") -> int:
79
+ if source:
80
+ keys = [k for k in self._cache if self._cache[k].source == source]
81
+ else:
82
+ keys = list(self._cache.keys())
83
+ for k in keys:
84
+ del self._cache[k]
85
+ return len(keys)
86
+
87
+ def _load_snapshot_from_db(self, snapshot_id: str) -> MarketSnapshot | None:
88
+ try:
89
+ snap = self._db.get_market_snapshot(snapshot_id)
90
+ except Exception:
91
+ return None
92
+ if snap is None:
93
+ return None
94
+ self._cache[snap.snapshot_id] = snap
95
+ return snap
96
+
97
+ def _latest_snapshot_from_db(self, source: str | None = None) -> MarketSnapshot | None:
98
+ if self._db is None:
99
+ return None
100
+ source = (source or "").strip()
101
+ try:
102
+ if source:
103
+ row = self._db.conn.execute(
104
+ "SELECT snapshot_id FROM market_snapshots WHERE source = ? ORDER BY captured_at DESC LIMIT 1",
105
+ (source,),
106
+ ).fetchone()
107
+ else:
108
+ row = self._db.conn.execute(
109
+ "SELECT snapshot_id FROM market_snapshots ORDER BY captured_at DESC LIMIT 1"
110
+ ).fetchone()
111
+ except Exception as exc:
112
+ logger.warning("Failed to read latest snapshot from DB: %s", exc)
113
+ return None
114
+ if not row:
115
+ return None
116
+ return self._load_snapshot_from_db(row["snapshot_id"])
117
+
118
+ def _latest_snapshot_any(self) -> MarketSnapshot | None:
119
+ return self._latest_snapshot_from_db()
120
+
121
+ def _list_snapshots_from_db(self, source: str = "") -> list[MarketSnapshot]:
122
+ if self._db is None:
123
+ return []
124
+ try:
125
+ if source:
126
+ rows = self._db.conn.execute(
127
+ "SELECT snapshot_id FROM market_snapshots WHERE source = ? ORDER BY captured_at DESC",
128
+ (source,),
129
+ ).fetchall()
130
+ else:
131
+ rows = self._db.conn.execute(
132
+ "SELECT snapshot_id FROM market_snapshots ORDER BY captured_at DESC"
133
+ ).fetchall()
134
+ except Exception as exc:
135
+ logger.warning("Failed to list snapshots from DB: %s", exc)
136
+ return []
137
+
138
+ snapshots: list[MarketSnapshot] = []
139
+ for row in rows:
140
+ snap = self._load_snapshot_from_db(row["snapshot_id"])
141
+ if snap is not None:
142
+ snapshots.append(snap)
143
+ return snapshots
144
+
145
+ # Backward-compatible API retained for callers that imported `latest` directly.
146
+ def latest_snapshot(self, source: str = "") -> MarketSnapshot | None:
147
+ return self.latest(source)
148
+
149
+ # Backward-compatible API retained for callers that imported `list` directly.
150
+ def list_snapshots(self, source: str = "") -> list[MarketSnapshot]:
151
+ return self.list(source)
152
+
153
+ # Keep existing method names for compatibility with `build_registry` callers.
154
+ def clear_cache(self, source: str = "") -> int:
155
+ return self.clear(source)
156
+
157
+ def get_cache_size(self) -> int:
158
+ return len(self._cache)
159
+
160
+ def _persist(self, snapshot: MarketSnapshot) -> None:
161
+ if self._db is None:
162
+ return
163
+ try:
164
+ self._db.save_market_snapshot(snapshot)
165
+ except Exception as exc:
166
+ logger.warning("Failed to persist snapshot %s: %s", snapshot.snapshot_id, exc)
167
+
168
+
169
+ def snapshot_freshness(snapshot: MarketSnapshot) -> dict[str, Any]:
170
+ """Compute freshness metadata for any MarketSnapshot.
171
+
172
+ Returns dict with:
173
+ - age_days: int
174
+ - is_stale: bool
175
+ - label: str
176
+ - captured_at: str
177
+ """
178
+ from datetime import date
179
+
180
+ if not snapshot or not snapshot.captured_at:
181
+ return {"age_days": 0, "is_stale": False, "label": "unknown"}
182
+
183
+ try:
184
+ captured = date.fromisoformat(snapshot.captured_at[:10])
185
+ age = (date.today() - captured).days
186
+ except (ValueError, TypeError):
187
+ return {"age_days": 0, "is_stale": False, "label": "unknown"}
188
+
189
+ is_stale = age > 1
190
+ if age == 0:
191
+ label = "Today's data"
192
+ elif age == 1:
193
+ label = "Yesterday's data"
194
+ else:
195
+ label = f"{age} days old"
196
+
197
+ return {
198
+ "age_days": age,
199
+ "is_stale": is_stale,
200
+ "label": label,
201
+ "captured_at": snapshot.captured_at,
202
+ }
shopstack/market/sources/_swiggy_adapter.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SwiggyAdapter — wraps Swiggy market source into MarketSourceAdapter protocol.
2
+
3
+ This adapter lets the source-agnostic registry (SourceRegistry) treat
4
+ Swiggy like any other market source. New sources (Blinkit, Zepto, DMart)
5
+ should create their own adapter in a separate module and register it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from shopstack.market.schema import MarketSnapshot
15
+ from shopstack.market.sources._adapter import MarketSourceAdapter
16
+ from shopstack.market.analytics import available_canonical_names as _swiggy_available
17
+ from shopstack.market.sources.swiggy import (
18
+ load_snapshot as _load_snapshot,
19
+ snapshot_freshness as _snapshot_freshness,
20
+ SOURCE_ID,
21
+ SOURCE_CATEGORY,
22
+ )
23
+
24
+
25
+ class SwiggyAdapter(MarketSourceAdapter):
26
+ """Adapter wrapping the existing Swiggy loader into the protocol."""
27
+
28
+ source_id: str = SOURCE_ID
29
+ source_category: str = SOURCE_CATEGORY
30
+
31
+ def __init__(self, data_dir: Path | None = None):
32
+ self._data_dir = data_dir
33
+
34
+ def load_snapshot(self) -> MarketSnapshot:
35
+ return _load_snapshot(data_dir=self._data_dir)
36
+
37
+ def freshness(self, snapshot: MarketSnapshot) -> dict[str, Any]:
38
+ return _snapshot_freshness(snapshot, today=date.today())
39
+
40
+ def available_canonical_names(self, snapshot: MarketSnapshot) -> set[str]:
41
+ return _swiggy_available(snapshot)
42
+
43
+
44
+ __all__ = ["SwiggyAdapter"]
shopstack/market/sources/_zepto_adapter.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Zepto market source adapter — thin config over shared JSON adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import date
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from shopstack.market.schema import MarketSnapshot
10
+ from shopstack.market.sources._adapter import MarketSourceAdapter
11
+ from shopstack.market.sources._json_adapter import (
12
+ JsonSourceConfig,
13
+ _normalize_record_shared,
14
+ _load_raw_json,
15
+ snapshot_freshness,
16
+ load_snapshot as _shared_load_snapshot,
17
+ )
18
+
19
+ SOURCE_ID = "zepto"
20
+ SOURCE_CATEGORY = "fresh_vegetables"
21
+ DEFAULT_SNAPSHOT_ID = f"{SOURCE_ID}_{SOURCE_CATEGORY}_2026-06-06"
22
+ DEFAULT_CAPTURED_AT = "2026-06-06"
23
+
24
+ CONFIG = JsonSourceConfig(
25
+ source_id=SOURCE_ID,
26
+ source_category=SOURCE_CATEGORY,
27
+ file_glob="zepto_fresh_vegetables_*.json",
28
+ name_field="item_name",
29
+ price_field="sale_price",
30
+ original_price_field="original_price",
31
+ )
32
+
33
+
34
+ def load_raw(data_dir: Path | None = None):
35
+ return _load_raw_json(CONFIG.file_glob, data_dir)
36
+
37
+
38
+ def load_snapshot(
39
+ data_dir: Path | None = None,
40
+ snapshot_id: str | None = None,
41
+ captured_at: str | None = None,
42
+ ):
43
+ return _shared_load_snapshot(CONFIG, data_dir=data_dir, snapshot_id=snapshot_id, captured_at=captured_at)
44
+
45
+
46
+ def normalize_record(
47
+ raw: dict[str, Any],
48
+ snapshot_id: str = DEFAULT_SNAPSHOT_ID,
49
+ captured_at: str = DEFAULT_CAPTURED_AT,
50
+ ) -> Any:
51
+ from dataclasses import replace
52
+ cfg = replace(CONFIG, captured_at=captured_at)
53
+ return _normalize_record_shared(raw, cfg)
54
+
55
+
56
+ class ZeptoAdapter(MarketSourceAdapter):
57
+ source_id: str = SOURCE_ID
58
+ source_category: str = SOURCE_CATEGORY
59
+
60
+ def __init__(self, data_dir: Path | None = None):
61
+ self._data_dir = data_dir
62
+
63
+ def load_snapshot(self) -> MarketSnapshot:
64
+ return _shared_load_snapshot(CONFIG, data_dir=self._data_dir)
65
+
66
+ def freshness(self, snapshot: MarketSnapshot) -> dict[str, Any]:
67
+ return snapshot_freshness(snapshot, today=date.today())
68
+
69
+ def available_canonical_names(self, snapshot: MarketSnapshot) -> set[str]:
70
+ from shopstack.market.analytics import available_canonical_names as _available
71
+ return _available(snapshot)
72
+
73
+
74
+ __all__ = [
75
+ "ZeptoAdapter",
76
+ "CONFIG",
77
+ "DEFAULT_CAPTURED_AT",
78
+ "DEFAULT_SNAPSHOT_ID",
79
+ "SOURCE_ID",
80
+ "load_raw",
81
+ "load_snapshot",
82
+ "normalize_record",
83
+ "snapshot_freshness",
84
+ ]
shopstack/market/sources/swiggy.py ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ import logging
6
+ from datetime import date
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from ..normalization import (
11
+ SizeParseResult,
12
+ canonicalize_name,
13
+ compute_unit_prices,
14
+ parse_size,
15
+ )
16
+ from ..schema import MarketSnapshot, NormalizedMarketRecord
17
+ from shopstack.schemas.models import PriceObservation
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ SOURCE_ID = "swiggy"
22
+ SOURCE_CATEGORY = "fresh_vegetables"
23
+ DEFAULT_SNAPSHOT_ID = f"{SOURCE_ID}_{SOURCE_CATEGORY}_2026-06-06"
24
+ DEFAULT_CAPTURED_AT = "2026-06-06"
25
+ FRESHNESS_WARNING_DAYS = 1
26
+
27
+ _DEFAULT_DATA_DIR = Path(__file__).resolve().parents[3] / "data"
28
+
29
+
30
+ def _find_json_file(data_dir: Path | None = None) -> Path:
31
+ d = data_dir or _DEFAULT_DATA_DIR
32
+ candidates = sorted(d.glob("swiggy_fresh_vegetables_cards_*.json"))
33
+ if not candidates:
34
+ raise FileNotFoundError(
35
+ f"No swiggy_fresh_vegetables_cards_*.json found in {d}"
36
+ )
37
+ return candidates[-1]
38
+
39
+
40
+ def _find_csv_file(data_dir: Path | None = None) -> Path:
41
+ d = data_dir or _DEFAULT_DATA_DIR
42
+ candidates = sorted(d.glob("swiggy_fresh_vegetables_cards_*.csv"))
43
+ if not candidates:
44
+ raise FileNotFoundError(
45
+ f"No swiggy_fresh_vegetables_cards_*.csv found in {d}"
46
+ )
47
+ return candidates[-1]
48
+
49
+
50
+ def load_raw_json(data_dir: Path | None = None) -> list[dict[str, Any]]:
51
+ fp = _find_json_file(data_dir)
52
+ with open(fp, encoding="utf-8") as f:
53
+ return json.load(f)
54
+
55
+
56
+ def load_raw_csv(data_dir: Path | None = None) -> list[dict[str, Any]]:
57
+ fp = _find_csv_file(data_dir)
58
+ with open(fp, encoding="utf-8") as f:
59
+ return list(csv.DictReader(f))
60
+
61
+
62
+ def load_raw(data_dir: Path | None = None) -> list[dict[str, Any]]:
63
+ """Load raw Swiggy cards from JSON (preferred) or CSV fallback."""
64
+ try:
65
+ return load_raw_json(data_dir)
66
+ except FileNotFoundError:
67
+ return load_raw_csv(data_dir)
68
+
69
+
70
+ def _coerce_float(val: Any, default: float = 0.0) -> float:
71
+ if val is None or val == "":
72
+ return default
73
+ try:
74
+ return float(val)
75
+ except (ValueError, TypeError):
76
+ return default
77
+
78
+
79
+ def _coerce_int(val: Any, default: int = 0) -> int:
80
+ if val is None or val == "":
81
+ return default
82
+ try:
83
+ return int(val)
84
+ except (ValueError, TypeError):
85
+ return default
86
+
87
+
88
+ def normalize_record(
89
+ raw: dict[str, Any],
90
+ snapshot_id: str = DEFAULT_SNAPSHOT_ID,
91
+ captured_at: str = DEFAULT_CAPTURED_AT,
92
+ ) -> NormalizedMarketRecord:
93
+ raw_name = str(raw.get("name", "")).strip()
94
+ raw_size = str(raw.get("size", "")).strip()
95
+
96
+ canonical, variety, components = canonicalize_name(raw_name)
97
+ size_result: SizeParseResult = parse_size(raw_size)
98
+
99
+ is_combo = len(components) > 1 or size_result.is_combo
100
+ tag = str(raw.get("tag", "")).strip()
101
+ availability = str(raw.get("availability", "")).strip()
102
+ is_available = availability.lower() == "available"
103
+
104
+ price = _coerce_float(raw.get("price_inr"))
105
+ mrp = _coerce_float(raw.get("mrp_inr"))
106
+
107
+ unit_prices = compute_unit_prices(
108
+ price=price,
109
+ quantity=size_result.normalized_quantity,
110
+ unit=size_result.normalized_unit,
111
+ is_weight_based=size_result.is_weight_based,
112
+ is_piece_based=size_result.is_piece_based,
113
+ )
114
+
115
+ warnings: list[str] = list(size_result.warnings or [])
116
+
117
+ return NormalizedMarketRecord(
118
+ source=SOURCE_ID,
119
+ source_category=SOURCE_CATEGORY,
120
+ raw_name=raw_name,
121
+ canonical_name=canonical,
122
+ description=str(raw.get("description", "")).strip(),
123
+ raw_size=raw_size,
124
+ normalized_quantity=size_result.normalized_quantity,
125
+ normalized_unit=size_result.normalized_unit,
126
+ package_count=size_result.package_count,
127
+ is_combo=is_combo,
128
+ is_weight_based=size_result.is_weight_based,
129
+ is_piece_based=size_result.is_piece_based,
130
+ is_size_class=size_result.is_size_class,
131
+ size_class=size_result.size_class,
132
+ price_inr=price,
133
+ mrp_inr=mrp,
134
+ discount_percent_displayed=_coerce_float(
135
+ raw.get("discount_percent")
136
+ ),
137
+ discount_amount_inr=_coerce_float(
138
+ raw.get("discount_amount_inr")
139
+ ),
140
+ computed_discount_percent=_coerce_float(
141
+ raw.get("computed_discount_percent")
142
+ ),
143
+ availability=availability,
144
+ is_available=is_available,
145
+ tag=tag,
146
+ is_ad=tag.lower() == "ad",
147
+ is_upgrade=tag.lower() == "upgrade",
148
+ card_index=_coerce_int(raw.get("card_index")),
149
+ delivery_time=str(raw.get("delivery_time", "")).strip(),
150
+ captured_at=captured_at,
151
+ snapshot_id=snapshot_id,
152
+ price_per_kg=unit_prices["price_per_kg"],
153
+ price_per_100g=unit_prices["price_per_100g"],
154
+ price_per_piece=unit_prices["price_per_piece"],
155
+ normalization_warnings=warnings,
156
+ component_names=components or [],
157
+ variety=variety,
158
+ )
159
+
160
+
161
+ def load_snapshot(
162
+ data_dir: Path | None = None,
163
+ snapshot_id: str | None = None,
164
+ captured_at: str | None = None,
165
+ ) -> MarketSnapshot:
166
+ raw_records = load_raw(data_dir)
167
+ sid = snapshot_id or DEFAULT_SNAPSHOT_ID
168
+ cat = captured_at or DEFAULT_CAPTURED_AT
169
+
170
+ normalized = [normalize_record(r, sid, cat) for r in raw_records]
171
+
172
+ return MarketSnapshot(
173
+ snapshot_id=sid,
174
+ source=SOURCE_ID,
175
+ source_category=SOURCE_CATEGORY,
176
+ captured_at=cat,
177
+ raw_records=raw_records,
178
+ normalized_records=normalized,
179
+ )
180
+
181
+
182
+ def snapshot_freshness(snapshot: MarketSnapshot, today: date | None = None) -> dict[str, Any]:
183
+ """Return freshness metadata for a Swiggy point-in-time snapshot."""
184
+ current = today or date.today()
185
+ try:
186
+ captured = date.fromisoformat(snapshot.captured_at[:10])
187
+ except (ValueError, TypeError):
188
+ return {
189
+ "captured_at": snapshot.captured_at,
190
+ "age_days": None,
191
+ "is_stale": True,
192
+ "label": f"Snapshot date unclear: {snapshot.captured_at or 'unknown'}",
193
+ }
194
+
195
+ age_days = (current - captured).days
196
+ if age_days <= 0:
197
+ label = f"Captured today ({snapshot.captured_at})"
198
+ elif age_days == 1:
199
+ label = f"Captured yesterday ({snapshot.captured_at})"
200
+ else:
201
+ label = f"Captured {age_days} days ago ({snapshot.captured_at})"
202
+ return {
203
+ "captured_at": snapshot.captured_at,
204
+ "age_days": age_days,
205
+ "is_stale": age_days > FRESHNESS_WARNING_DAYS,
206
+ "label": label,
207
+ }
208
+
209
+
210
+ def import_swiggy_snapshot_to_db(
211
+ db: Any,
212
+ data_dir: Path | None = None,
213
+ dry_run: bool = False,
214
+ ) -> dict[str, Any]:
215
+ """Import a Swiggy snapshot into the ``price_observations`` table.
216
+
217
+ Composes the pure loader (:func:`load_snapshot`) with the DB
218
+ writer (``db.record_price``). Restores the behavior of the
219
+ deprecated ``import_swiggy_fresh_vegetables_snapshot``
220
+ function that was removed in Pass 9 supersession
221
+ (see Pass 9 addendum: "Add features back to canonical").
222
+
223
+ The canonical architecture separates loader (pure) from
224
+ DB write (this function). Tests for the loader are in
225
+ :mod:`tests.test_market` (:class:`TestSwiggyLoader`); tests
226
+ for the DB write are in
227
+ :mod:`tests.test_market_swiggy_migration`
228
+ (:class:`TestSwiggyMigrationImport`).
229
+
230
+ Args:
231
+ db: A :class:`shopstack.persistence.database.Database`
232
+ instance (or any object with a ``record_price``
233
+ method that takes a :class:`PriceObservation`).
234
+ data_dir: Optional override for the data directory
235
+ containing the swiggy JSON/CSV snapshot files.
236
+ dry_run: If True, parse and validate but don't write
237
+ to the DB. Useful for smoke testing.
238
+
239
+ Returns:
240
+ A summary dict with ``imported_records``,
241
+ ``skipped_records``, ``source_event_id``, and
242
+ ``source_file`` (the path of the loaded snapshot).
243
+ """
244
+ snapshot = load_snapshot(data_dir=data_dir)
245
+ try:
246
+ captured = date.fromisoformat(snapshot.captured_at[:10])
247
+ except (ValueError, TypeError):
248
+ # If the captured_at is malformed, use today as a fallback
249
+ # so the import still works. This matches the legacy
250
+ # behavior of the deprecated function.
251
+ captured = date.today()
252
+
253
+ imported = 0
254
+ skipped = 0
255
+ for record in snapshot.normalized_records:
256
+ if record.price_inr <= 0 or (record.normalized_quantity or 0) <= 0:
257
+ skipped += 1
258
+ continue
259
+ observation = PriceObservation(
260
+ canonical_name=record.canonical_name,
261
+ quantity=record.normalized_quantity,
262
+ unit=record.normalized_unit or "unit",
263
+ price=record.price_inr,
264
+ currency="INR",
265
+ store_name="Swiggy Instamart",
266
+ observation_date=captured,
267
+ source_event_id=snapshot.snapshot_id,
268
+ notes=f"raw={record.raw_name}",
269
+ )
270
+ if not dry_run:
271
+ db.record_price(observation)
272
+ imported += 1
273
+
274
+ return {
275
+ "imported_records": imported,
276
+ "skipped_records": skipped,
277
+ "source_event_id": snapshot.snapshot_id,
278
+ "source_file": str(data_dir or _DEFAULT_DATA_DIR),
279
+ }
shopstack/memory/waste_patterns.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from shopstack.persistence.database import Database
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def compute_waste_pattern(database: Database, canonical_name: str) -> dict[str, Any]:
12
+ """Compute how often an item is wasted vs consumed.
13
+
14
+ Uses the real ``inventory_events`` table. "Wasted" is recorded as
15
+ ``action = 'discarded'`` and "consumed" as ``action = 'consumed'``;
16
+ quantity is read from the absolute value of ``quantity_delta``.
17
+ """
18
+ try:
19
+ rows = database.conn.execute(
20
+ """
21
+ SELECT action,
22
+ COALESCE(SUM(ABS(quantity_delta)), 0.0) AS total_qty
23
+ FROM inventory_events
24
+ WHERE canonical_name = ?
25
+ AND action IN ('discarded', 'consumed')
26
+ AND quantity_delta IS NOT NULL
27
+ GROUP BY action
28
+ """,
29
+ (canonical_name.lower(),),
30
+ ).fetchall()
31
+ except Exception as e:
32
+ logger.warning("Waste pattern compute failed: %s", e)
33
+ return {"wasted_qty": 0, "consumed_qty": 0, "waste_rate": 0.0, "total_resolved": 0.0}
34
+
35
+ wasted = 0.0
36
+ consumed = 0.0
37
+ for r in rows:
38
+ qty = float(r["total_qty"] or 0.0)
39
+ if r["action"] == "discarded":
40
+ wasted = qty
41
+ elif r["action"] == "consumed":
42
+ consumed = qty
43
+
44
+ total = wasted + consumed
45
+ waste_rate = (wasted / total) if total > 0 else 0.0
46
+ return {
47
+ "wasted_qty": wasted,
48
+ "consumed_qty": consumed,
49
+ "waste_rate": waste_rate,
50
+ "total_resolved": total,
51
+ }
52
+
53
+
54
+ def get_waste_insights(database: Database) -> list[dict[str, Any]]:
55
+ """Return per-item waste insights aggregated across the whole inventory.
56
+
57
+ Scopes the result to items the household has actually moved through
58
+ the inventory events table.
59
+ """
60
+ try:
61
+ rows = database.conn.execute(
62
+ """
63
+ SELECT canonical_name,
64
+ action,
65
+ COALESCE(SUM(ABS(quantity_delta)), 0.0) AS total_qty
66
+ FROM inventory_events
67
+ WHERE action IN ('discarded', 'consumed')
68
+ AND quantity_delta IS NOT NULL
69
+ AND canonical_name <> ''
70
+ GROUP BY canonical_name, action
71
+ """,
72
+ ).fetchall()
73
+ except Exception as e:
74
+ logger.warning("Waste insights failed: %s", e)
75
+ return []
76
+
77
+ stats: dict[str, dict[str, float]] = {}
78
+ for r in rows:
79
+ name = r["canonical_name"]
80
+ action = r["action"]
81
+ qty = float(r["total_qty"] or 0.0)
82
+ bucket = stats.setdefault(name, {"wasted": 0.0, "consumed": 0.0})
83
+ if action == "discarded":
84
+ bucket["wasted"] += qty
85
+ elif action == "consumed":
86
+ bucket["consumed"] += qty
87
+
88
+ results: list[dict[str, Any]] = []
89
+ for name, data in stats.items():
90
+ wasted = data["wasted"]
91
+ consumed = data["consumed"]
92
+ total = wasted + consumed
93
+ if total <= 0:
94
+ continue
95
+ results.append({
96
+ "canonical_name": name,
97
+ "wasted_qty": wasted,
98
+ "consumed_qty": consumed,
99
+ "waste_rate": wasted / total,
100
+ "total_resolved": total,
101
+ })
102
+
103
+ results.sort(key=lambda r: r["waste_rate"], reverse=True)
104
+ return results
shopstack/model_registry.py ADDED
@@ -0,0 +1,746 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Literal
5
+
6
+ RuntimeType = Literal["transformers", "llama.cpp", "gguf", "onnx", "diffusers", "custom", "mock", "mlx"]
7
+ BadgeRelevance = Literal["llama_champion", "well_tuned", "off_the_grid", "none"]
8
+ ModelStatus = Literal["candidate", "active", "deprecated", "rejected"]
9
+ MAX_ACTIVE_MODEL_PARAMS_B = 32.0
10
+
11
+
12
+ @dataclass
13
+ class ModelEntry:
14
+ provider_group: str
15
+ model_id: str
16
+ hf_model: str
17
+ params_b: float
18
+ license_note: str
19
+ runtime: RuntimeType
20
+ status: ModelStatus
21
+ badge_relevance: BadgeRelevance = "none"
22
+ python_requires: str = ">=3.10"
23
+ notes: str = ""
24
+
25
+
26
+ MODEL_REGISTRY: list[ModelEntry] = [
27
+ # STT
28
+ ModelEntry(
29
+ provider_group="stt",
30
+ model_id="qwen3-asr-1.7b",
31
+ hf_model="Qwen/Qwen3-ASR-1.7B",
32
+ params_b=1.7,
33
+ license_note="Apache-2.0",
34
+ runtime="transformers",
35
+ status="candidate",
36
+ notes="top candidate for household commands — provider wired as qwen3_asr backend. Bench pending. Demoted to candidate because SenseVoiceSmall is the default. Switch via stt_backend config.",
37
+ ),
38
+ # STT — Voxtral-Mini-4B-Realtime-2602 (Jan 2026, 1.1M downloads, Mistral's new SOTA)
39
+ ModelEntry(
40
+ provider_group="stt",
41
+ model_id="voxtral-mini-4b-realtime",
42
+ hf_model="mistralai/Voxtral-Mini-4B-Realtime-2602",
43
+ params_b=4.0,
44
+ license_note="Apache-2.0",
45
+ runtime="vllm",
46
+ status="candidate",
47
+ badge_relevance="llama_champion",
48
+ notes="Voxtral-Mini-4B-Realtime-2602 (Jan 2026, 1.1M downloads). Mistral's new SOTA STT. Apache-2.0. STT bench in flight on Modal A10G with 20 Hinglish audios.",
49
+ ),
50
+ # STT — parakeet-tdt-0.6b-v3 (Aug 2025, 120k downloads, newer than v2)
51
+ ModelEntry(
52
+ provider_group="stt",
53
+ model_id="parakeet-tdt-0.6b-v3",
54
+ hf_model="nvidia/parakeet-tdt-0.6b-v3",
55
+ params_b=0.6,
56
+ license_note="CC-BY-4.0",
57
+ runtime="nemo",
58
+ status="candidate",
59
+ notes="parakeet-tdt-0.6b-v3 (Aug 2025, 120k downloads). Newer than the v2 already in registry. STT bench in flight.",
60
+ ),
61
+ # STT — Fun-ASR-Nano-2512 (Dec 2025, FunAudioLLM)
62
+ ModelEntry(
63
+ provider_group="stt",
64
+ model_id="fun-asr-nano-2512",
65
+ hf_model="FunAudioLLM/Fun-ASR-Nano-2512",
66
+ params_b=0.5,
67
+ license_note="Apache-2.0",
68
+ runtime="funasr",
69
+ status="candidate",
70
+ notes="Fun-ASR-Nano-2512 (Dec 2025, FunAudioLLM). Multilingual + streaming + diarization. STT bench pending.",
71
+ ),
72
+ ModelEntry(
73
+ provider_group="stt",
74
+ model_id="parakeet-0.6b",
75
+ hf_model="nvidia/parakeet-ctc-0.6b",
76
+ params_b=0.6,
77
+ license_note="CC-BY-4.0",
78
+ runtime="transformers",
79
+ status="candidate",
80
+ notes="lightweight streaming ASR — provider wired as parakeet backend. Demoted to candidate (SenseVoiceSmall is the default).",
81
+ ),
82
+ ModelEntry(
83
+ provider_group="stt",
84
+ model_id="sense-voice-small",
85
+ hf_model="iic/SenseVoiceSmall",
86
+ params_b=0.2,
87
+ license_note="MIT",
88
+ runtime="transformers",
89
+ status="active",
90
+ notes="very fast, multilingual — wired as sensevoice backend (default). Modal STT v3 (13-Jun-2026): 75.2% WER, 46.4% slot retention on 20 Hinglish audios. The only working STT in the 32B cap.",
91
+ ),
92
+ ModelEntry(
93
+ provider_group="stt",
94
+ model_id="whisper-large-v3-turbo",
95
+ hf_model="openai/whisper-large-v3-turbo",
96
+ params_b=0.8,
97
+ license_note="MIT",
98
+ runtime="transformers",
99
+ status="candidate",
100
+ notes="baseline only",
101
+ ),
102
+ ModelEntry(
103
+ provider_group="stt",
104
+ model_id="qwen3-asr-0.6b",
105
+ hf_model="Qwen/Qwen3-ASR-0.6B",
106
+ params_b=0.6,
107
+ license_note="Apache-2.0",
108
+ runtime="transformers",
109
+ status="candidate",
110
+ notes="smaller/newer Qwen ASR variant to benchmark against Qwen3-ASR-1.7B; good for low-cost voice sweeps and short commands.",
111
+ ),
112
+ # TTS
113
+ ModelEntry(
114
+ provider_group="tts",
115
+ model_id="qwen3-tts-0.6b",
116
+ hf_model="Qwen/Qwen3-TTS-12Hz-0.6B-Base",
117
+ params_b=0.6,
118
+ license_note="Apache-2.0",
119
+ runtime="custom",
120
+ status="candidate",
121
+ notes="0.6B variant of Qwen3-TTS. Demoted to candidate (1.7B CustomVoice is the new default; see qwen3-tts-1.7b below). Smaller model, similar SDK, ~3x faster than 1.7B.",
122
+ ),
123
+ # TTS — Qwen3-TTS-12Hz-1.7B-CustomVoice (NEW ACTIVE WINNER 13-Jun-2026)
124
+ ModelEntry(
125
+ provider_group="tts",
126
+ model_id="qwen3-tts-1.7b-customvoice",
127
+ hf_model="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
128
+ params_b=1.7,
129
+ license_note="Apache-2.0",
130
+ runtime="custom",
131
+ status="active",
132
+ badge_relevance="llama_champion",
133
+ notes="Qwen3-TTS-12Hz-1.7B-CustomVoice (Aug 2025, 1.9M downloads). **Modal A10G TTS compare bench WINNER (13-Jun-2026): 20/20 synth, 5.99s mean, 24kHz, 0.1183 energy (2.5x more dynamic than Kokoro).** Quality path: 14x slower than Kokoro but expressive. Provider wired as qwen3_tts backend, default voice 'Ryan'.",
134
+ ),
135
+ ModelEntry(
136
+ provider_group="tts",
137
+ model_id="kokoro-82m",
138
+ hf_model="",
139
+ params_b=0.082,
140
+ license_note="Apache-2.0",
141
+ runtime="custom",
142
+ status="active",
143
+ badge_relevance="off_the_grid",
144
+ notes="extremely lightweight — KokoroTTSProvider wired as kokoro backend",
145
+ ),
146
+ # TTS — CosyVoice 2 (higher quality, Hindi support) — SUPERSEDED by CosyVoice 3
147
+ ModelEntry(
148
+ provider_group="tts",
149
+ model_id="cosyvoice2-0.5b",
150
+ hf_model="FunAudioLLM/CosyVoice2-0.5B",
151
+ params_b=0.5,
152
+ license_note="Apache-2.0",
153
+ runtime="custom",
154
+ status="rejected",
155
+ badge_relevance="off_the_grid",
156
+ notes="REJECTED 13-Jun-2026: superseded by Fun-CosyVoice3-0.5B-2512. CosyVoice 2 was blocked on Python 3.14 (matcha-tts/numpy 1.24.3/distutils). Use CosyVoice 3 instead.",
157
+ ),
158
+ # TTS — Fun-CosyVoice3-0.5B-2512 (Dec 2025, REPLACES CosyVoice 2)
159
+ ModelEntry(
160
+ provider_group="tts",
161
+ model_id="fun-cosyvoice3-0.5b-2512",
162
+ hf_model="FunAudioLLM/Fun-CosyVoice3-0.5B-2512",
163
+ params_b=0.5,
164
+ license_note="Apache-2.0",
165
+ runtime="custom",
166
+ status="candidate",
167
+ badge_relevance="off_the_grid",
168
+ notes="Fun-CosyVoice3-0.5B-2512 (Dec 2025, 81k downloads). Apache-2.0. REPLACES CosyVoice 2. Multilingual (zh, en, ja, ko, de, fr, ru, it, es). MLX mirror available (mlx-community/Fun-CosyVoice3-0.5B-2512-fp16). Unblocks local Hindi TTS.",
169
+ ),
170
+ # TTS — Qwen3-TTS-12Hz-1.7B-CustomVoice (Jan 2026, 1.9M downloads)
171
+ ModelEntry(
172
+ provider_group="tts",
173
+ model_id="qwen3-tts-1.7b-customvoice",
174
+ hf_model="Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice",
175
+ params_b=1.7,
176
+ license_note="Apache-2.0",
177
+ runtime="transformers",
178
+ status="candidate",
179
+ badge_relevance="llama_champion",
180
+ notes="Qwen3-TTS-1.7B-CustomVoice (Jan 2026, 1.9M downloads). Apache-2.0. Most popular Qwen TTS. TTS bench pending.",
181
+ ),
182
+ # TTS — Qwen3-TTS-12Hz-1.7B-VoiceDesign
183
+ ModelEntry(
184
+ provider_group="tts",
185
+ model_id="qwen3-tts-1.7b-voicedesign",
186
+ hf_model="Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign",
187
+ params_b=1.7,
188
+ license_note="Apache-2.0",
189
+ runtime="transformers",
190
+ status="candidate",
191
+ notes="Qwen3-TTS-1.7B-VoiceDesign (Jan 2026, 686k downloads). Voice design variant. TTS bench pending.",
192
+ ),
193
+ # Vision
194
+ ModelEntry(
195
+ provider_group="vision",
196
+ model_id="minicpm-v-8b",
197
+ hf_model="openbmb/MiniCPM-V-2_6",
198
+ params_b=8.0,
199
+ license_note="Apache-2.0",
200
+ runtime="transformers",
201
+ status="candidate",
202
+ badge_relevance="llama_champion",
203
+ notes="strong VLM for household items — provider wired as minicpmv backend. Demoted to candidate (Qwen3-VL-8B is the new default; see qwen3-vl-8b below).",
204
+ ),
205
+ # Vision — Qwen2.5-VL-3B (lighter alternative via MLX)
206
+ ModelEntry(
207
+ provider_group="vision",
208
+ model_id="qwen2.5-vl-3b",
209
+ hf_model="Qwen/Qwen2.5-VL-3B-Instruct",
210
+ params_b=3.0,
211
+ license_note="Apache-2.0",
212
+ runtime="mlx",
213
+ status="candidate",
214
+ badge_relevance="off_the_grid",
215
+ notes="3B params, 0.9s load via MLX. Excellent throughput (43-80 tok/s) at 1/3 the params of MiniCPM-V. Demoted to candidate (Qwen3-VL-8B is the new default; see qwen3-vl-8b). Use via MLX for high-volume vision tasks.",
216
+ ),
217
+ # Vision — MiniCPM-V-4.6 (NEW MID-2026 SOTA, VLM bench candidate)
218
+ ModelEntry(
219
+ provider_group="vision",
220
+ model_id="minicpm-v-4.6",
221
+ hf_model="openbmb/MiniCPM-V-4.6",
222
+ params_b=4.6,
223
+ license_note="Apache-2.0",
224
+ runtime="transformers",
225
+ status="candidate",
226
+ badge_relevance="llama_champion",
227
+ notes="MiniCPM-V-4.6 (Apr 2026, 660k downloads). On-device VLM. REPLACES MiniCPM-V-2_6 (Aug 2024). VLM bench in flight.",
228
+ ),
229
+ # Vision — Molmo2-8B (Dec 2025, Allen AI, 645k downloads)
230
+ ModelEntry(
231
+ provider_group="vision",
232
+ model_id="molmo2-8b",
233
+ hf_model="allenai/Molmo2-8B",
234
+ params_b=8.0,
235
+ license_note="Apache-2.0",
236
+ runtime="transformers",
237
+ status="candidate",
238
+ badge_relevance="llama_champion",
239
+ notes="Molmo2-8B (Dec 2025, 645k downloads). Allen AI. custom_code required. VLM bench in flight.",
240
+ ),
241
+ # Vision — Qwen2.5-VL-7B-Instruct (Jan 2025, 6.5M downloads, still very popular)
242
+ ModelEntry(
243
+ provider_group="vision",
244
+ model_id="qwen2.5-vl-7b",
245
+ hf_model="Qwen/Qwen2.5-VL-7B-Instruct",
246
+ params_b=7.0,
247
+ license_note="Apache-2.0",
248
+ runtime="transformers",
249
+ status="candidate",
250
+ badge_relevance="llama_champion",
251
+ notes="Qwen2.5-VL-7B-Instruct (Jan 2025, 6.5M downloads). Modal VLM v8 (13-Jun-2026): 86% on synthetic product images (95% identify, 95% brand, 100% qty, 40% price, 100% expiry). Works via AutoModelForImageTextToText + transformers>=4.55.",
252
+ ),
253
+ # Vision — Qwen3-VL-8B-Instruct (NEW ACTIVE WINNER, 13-Jun-2026)
254
+ ModelEntry(
255
+ provider_group="vision",
256
+ model_id="qwen3-vl-8b",
257
+ hf_model="Qwen/Qwen3-VL-8B-Instruct",
258
+ params_b=8.0,
259
+ license_note="Apache-2.0",
260
+ runtime="transformers",
261
+ status="active",
262
+ badge_relevance="llama_champion",
263
+ notes="NEW ACTIVE — Modal A100 int4 (13-Jun-2026): 99% overall on synthetic product images (100% identify, 100% brand, 100% qty, 95% price, 100% expiry). 7.3M downloads (most popular Qwen VLM). Apache-2.0. Best vision result across all benches. Use via AutoModelForImageTextToText + transformers>=4.55.",
264
+ ),
265
+ # Vision — Kimi-VL-A3B-Thinking (Apr 2025, MoE vision reasoning)
266
+ ModelEntry(
267
+ provider_group="vision",
268
+ model_id="kimi-vl-a3b-thinking",
269
+ hf_model="moonshotai/Kimi-VL-A3B-Thinking",
270
+ params_b=16.0,
271
+ license_note="MIT",
272
+ runtime="transformers",
273
+ status="candidate",
274
+ badge_relevance="llama_champion",
275
+ notes="Kimi-VL-A3B-Thinking (Apr 2025). MoE 16B/3B active, vision + reasoning. custom_code. VLM bench pending.",
276
+ ),
277
+ # Planner
278
+ ModelEntry(
279
+ provider_group="planner",
280
+ model_id="minicpm5-1b",
281
+ hf_model="openbmb/MiniCPM5-1B",
282
+ params_b=1.0,
283
+ license_note="Apache-2.0",
284
+ runtime="transformers",
285
+ status="candidate",
286
+ badge_relevance="well_tuned",
287
+ notes="lightweight planner / parser — provider wired as minicpm5 backend for tool_call_parser_backend. Demoted to candidate (Ministral-8B-Instruct-2410 is the main planner; see below).",
288
+ ),
289
+ ModelEntry(
290
+ provider_group="planner",
291
+ model_id="lfm2.5-8b-a1b-gguf",
292
+ hf_model="unsloth/LFM2.5-8B-A1B-GGUF",
293
+ params_b=8.3,
294
+ license_note="Apache-2.0",
295
+ runtime="gguf",
296
+ status="candidate",
297
+ badge_relevance="llama_champion",
298
+ notes="GGUF planner for llama.cpp path",
299
+ ),
300
+ ModelEntry(
301
+ provider_group="planner",
302
+ model_id="llama-3.2-3b-gguf",
303
+ hf_model="unsloth/Llama-3.2-3B-Instruct-GGUF",
304
+ params_b=3.0,
305
+ license_note="Llama 3.2 Community",
306
+ runtime="gguf",
307
+ status="candidate",
308
+ badge_relevance="none",
309
+ notes="downloaded & tested: 493ms for 49 tokens via llama.cpp. Superseded by qwen3.5-4b for planner (better tool-calling accuracy).",
310
+ ),
311
+ # Planner — Qwen3.5-4B (full bf16 variant, cached, used for accuracy-critical tasks)
312
+ ModelEntry(
313
+ provider_group="planner",
314
+ model_id="qwen3.5-4b",
315
+ hf_model="Qwen/Qwen3.5-4B",
316
+ params_b=4.0,
317
+ license_note="Apache-2.0",
318
+ runtime="mlx",
319
+ status="candidate",
320
+ badge_relevance="llama_champion",
321
+ notes="Full bf16 precision variant (8.9GB). ~18 tok/s via MLX. Demoted to candidate because config now defaults to 4-bit variant. Keep cached for quality-benchmarking (97.5% accuracy).",
322
+ ),
323
+ # Planner — Qwen3.5-4B-4bit (deployment variant, ~2.3GB, same accuracy)
324
+ ModelEntry(
325
+ provider_group="planner",
326
+ model_id="qwen3.5-4b-4bit",
327
+ hf_model="mlx-community/Qwen3.5-4B-4bit",
328
+ params_b=4.0,
329
+ license_note="Apache-2.0",
330
+ runtime="mlx",
331
+ status="rejected",
332
+ badge_relevance="llama_champion",
333
+ notes="REJECTED 13-Jun-2026: 70% on Modal A100 int4 production bench (Run 1+2+3). 4x slower than alternatives (28.18s vs 4.08s). Overthinking issue. Demote from default. Switch to Ministral-8B-Instruct-2410 (95%) or Ministral-3-8B-Reasoning-2512 (90%, mid-2026).",
334
+ ),
335
+ # Planner — Ministral-8B-Instruct-2410 (RUN 1+2 WINNER, 13-Jun-2026)
336
+ ModelEntry(
337
+ provider_group="planner",
338
+ model_id="ministral-8b-instruct",
339
+ hf_model="mistralai/Ministral-8B-Instruct-2410",
340
+ params_b=8.0,
341
+ license_note="Apache-2.0",
342
+ runtime="transformers",
343
+ status="active",
344
+ badge_relevance="llama_champion",
345
+ notes="Run 1+2 winner on Modal A100 int4 (13-Jun-2026): 90% (10 prompts) / **95% (20 prompts)**, 4.08s mean latency. Tied with Gemma-3-4B. Oct 2024 release. **CURRENT DEFAULT PLANNER** (config.py:28). MLX variant: mlx-community/Ministral-8B-Instruct-2410-4bit. Demoted Ministral-3-8B-Instruct-2512 (the same-arch non-reasoning variant at 70% loses to the 2512-Reasoning variant at 90%).",
346
+ ),
347
+ # Planner — Ministral-3-8B-Instruct-2512 (Oct 2025, non-reasoning)
348
+ ModelEntry(
349
+ provider_group="planner",
350
+ model_id="ministral-3-8b-instruct-2512",
351
+ hf_model="mistralai/Ministral-3-8B-Instruct-2512",
352
+ params_b=8.0,
353
+ license_note="Apache-2.0",
354
+ runtime="transformers",
355
+ status="candidate",
356
+ badge_relevance="llama_champion",
357
+ notes="Mistral's Oct 2025 release. 169k downloads. mistral3 arch. Modal A100 int4 (13-Jun-2026): 70% tool-calling, 2.59s mean. **Loses to Ministral-3-8B-Reasoning-2512 (90%) by 20 points** — the reasoning variant is materially better. Demoted.",
358
+ ),
359
+ # Planner — Ministral-3-3B-Instruct-2512 (NEW MID-2026 SOTA, Run 3 candidate)
360
+ ModelEntry(
361
+ provider_group="planner",
362
+ model_id="ministral-3-3b-instruct-2512",
363
+ hf_model="mistralai/Ministral-3-3B-Instruct-2512",
364
+ params_b=3.0,
365
+ license_note="Apache-2.0",
366
+ runtime="transformers",
367
+ status="candidate",
368
+ badge_relevance="llama_champion",
369
+ notes="Mistral's Oct 2025 3B variant. 669k downloads. Smallest serious Mistral. Run 3 bench in flight.",
370
+ ),
371
+ # Planner — Ministral-3-14B-Instruct-2512 (NEW MID-2026 SOTA, Run 3 candidate)
372
+ ModelEntry(
373
+ provider_group="planner",
374
+ model_id="ministral-3-14b-instruct-2512",
375
+ hf_model="mistralai/Ministral-3-14B-Instruct-2512-BF16",
376
+ params_b=14.0,
377
+ license_note="Apache-2.0",
378
+ runtime="transformers",
379
+ status="candidate",
380
+ badge_relevance="llama_champion",
381
+ notes="Mistral's Oct 2025 14B variant. Bigger brother of Ministral-3-8B. Run 3 bench in flight.",
382
+ ),
383
+ # Planner — Qwen2.5-7B-Instruct (NEW CANDIDATE, 13-Jun-2026)
384
+ ModelEntry(
385
+ provider_group="planner",
386
+ model_id="qwen2.5-7b-instruct",
387
+ hf_model="Qwen/Qwen2.5-7B-Instruct",
388
+ params_b=7.0,
389
+ license_note="Apache-2.0",
390
+ runtime="transformers",
391
+ status="candidate",
392
+ badge_relevance="llama_champion",
393
+ notes="Modal A100 int4 (13-Jun-2026): 80% tool-calling, 3.17s mean (FASTEST of all 8 candidates), 5.56GB GPU. Beats Qwen3.5-4B on speed by 9×. Strong runner-up. Pending: MLX port + 30+ prompt re-validation.",
394
+ ),
395
+ # Planner — Qwen3.5-9B (HF frontier candidate, 13-Jun-2026)
396
+ ModelEntry(
397
+ provider_group="planner",
398
+ model_id="qwen3.5-9b",
399
+ hf_model="Qwen/Qwen3.5-9B",
400
+ params_b=9.0,
401
+ license_note="Apache-2.0",
402
+ runtime="transformers",
403
+ status="candidate",
404
+ badge_relevance="llama_champion",
405
+ notes="Qwen3.5-9B (Feb 2026, 8.5M downloads). Modal bench (13-Jun-2026): 70% (17.35s — overthinking). Demote from consideration. Qwen2.5-7B better at 80% (3.10s).",
406
+ ),
407
+ # Planner — Ministral-3-8B-Reasoning-2512 (NEW MID-2026 WINNER, 13-Jun-2026)
408
+ ModelEntry(
409
+ provider_group="planner",
410
+ model_id="ministral-3-8b-reasoning-2512",
411
+ hf_model="mistralai/Ministral-3-8B-Reasoning-2512",
412
+ params_b=8.0,
413
+ license_note="Apache-2.0",
414
+ runtime="transformers",
415
+ status="candidate",
416
+ badge_relevance="llama_champion",
417
+ notes="Modal A100 int4 (13-Jun-2026): 90% tool-calling, 4.79s mean, Apache-2.0. Best mid-2026 candidate. Tied with Run 1/2 winner (Ministral-8B-Instruct-2410 at 95%). A/B candidate — switch via planner_backend config. mistral3 arch.",
418
+ ),
419
+ # Planner — Ministral-3-3B-Instruct-2512 (NEW BEST 3B, 13-Jun-2026)
420
+ ModelEntry(
421
+ provider_group="planner",
422
+ model_id="ministral-3-3b-instruct-2512",
423
+ hf_model="mistralai/Ministral-3-3B-Instruct-2512",
424
+ params_b=3.0,
425
+ license_note="Apache-2.0",
426
+ runtime="transformers",
427
+ status="candidate",
428
+ badge_relevance="llama_champion",
429
+ notes="Modal A100 int4 (13-Jun-2026): 85% tool-calling, 2.31s mean (fastest serious 3B), Apache-2.0. 669k downloads. Best 3B option. Demoted to candidate (Ministral-8B-Instruct-2410 is the main planner). LoRA v2 was trained on this base (80% on prod).",
430
+ ),
431
+ # Planner — Qwen3.6-27B-FP8 (Apr 2026, 4.7M downloads, under 32B cap)
432
+ ModelEntry(
433
+ provider_group="planner",
434
+ model_id="qwen3.6-27b-fp8",
435
+ hf_model="Qwen/Qwen3.6-27B-FP8",
436
+ params_b=27.0,
437
+ license_note="Apache-2.0",
438
+ runtime="transformers-fp8",
439
+ status="candidate",
440
+ badge_relevance="llama_champion",
441
+ notes="Qwen3.6-27B FP8 (Apr 2026, 4.7M downloads). Best under-32B MoE. Run 3 bench in flight.",
442
+ ),
443
+ # Planner — Qwen3-Coder-Next (Jan 2026, 912k downloads, code specialist)
444
+ ModelEntry(
445
+ provider_group="planner",
446
+ model_id="qwen3-coder-next",
447
+ hf_model="Qwen/Qwen3-Coder-Next",
448
+ params_b=32.0,
449
+ license_note="Apache-2.0",
450
+ runtime="transformers-int4",
451
+ status="candidate",
452
+ badge_relevance="llama_champion",
453
+ notes="Qwen3-Coder-Next (Jan 2026, 912k downloads). Code specialist, strong tool-calling. Run 3 bench in flight.",
454
+ ),
455
+ # Planner — Gemma 4 31B QAT (May 2026, Google's latest, 4-bit QAT)
456
+ ModelEntry(
457
+ provider_group="planner",
458
+ model_id="gemma-4-31b-qat",
459
+ hf_model="google/gemma-4-31B-it-qat-q4_0-unquantized-assistant",
460
+ params_b=31.0,
461
+ license_note="Apache-2.0",
462
+ runtime="transformers-q4",
463
+ status="candidate",
464
+ badge_relevance="llama_champion",
465
+ notes="Gemma 4 31B QAT q4_0 (May 2026, 9.9M downloads). Google's latest, Apache-2.0. Run 3 bench in flight.",
466
+ ),
467
+ # Planner — Qwen3.6-35B-A3B (HF frontier heavy candidate, 13-Jun-2026)
468
+ ModelEntry(
469
+ provider_group="planner",
470
+ model_id="qwen3.6-35b-a3b",
471
+ hf_model="Qwen/Qwen3.6-35B-A3B",
472
+ params_b=35.0,
473
+ license_note="Apache-2.0",
474
+ runtime="transformers",
475
+ status="candidate",
476
+ badge_relevance="llama_champion",
477
+ notes="HF Inference live-check (13-Jun-2026): available and responsive; 0.94s wall-clock for a 16-token smoke call. Remote frontier candidate for Modal/HF Pro sweeps only.",
478
+ ),
479
+ # Planner — Gemma 3 4B (strong architecture, needs prompt engineering)
480
+ ModelEntry(
481
+ provider_group="planner",
482
+ model_id="gemma-3-4b-it-4bit",
483
+ hf_model="mlx-community/gemma-3-4b-it-4bit",
484
+ params_b=4.0,
485
+ license_note="Gemma Terms of Use",
486
+ runtime="mlx",
487
+ status="candidate",
488
+ badge_relevance="none",
489
+ notes="Google Gemma 3 4B Instruct, 4-bit MLX quantized. Strong architecture but requires more prompt engineering for JSON output. Not yet downloaded or benchmarked.",
490
+ ),
491
+ # Planner — DeepSeek-R1-Distill-Qwen-7B (higher accuracy, heavier)
492
+ ModelEntry(
493
+ provider_group="planner",
494
+ model_id="deepseek-r1-distill-qwen-7b-4bit",
495
+ hf_model="mlx-community/DeepSeek-R1-Distill-Qwen-7B-abliterated-4bit",
496
+ params_b=7.0,
497
+ license_note="MIT",
498
+ runtime="mlx",
499
+ status="candidate",
500
+ badge_relevance="none",
501
+ notes="DeepSeek R1 Distill Qwen 7B, 4-bit MLX quantized. Higher accuracy potential but 7B params (~4GB). Not yet downloaded or benchmarked. Add to active only after verification.",
502
+ ),
503
+ # OCR / extraction
504
+ ModelEntry(
505
+ provider_group="ocr",
506
+ model_id="glm-ocr-0.9b",
507
+ hf_model="zai-org/GLM-OCR",
508
+ params_b=1.0,
509
+ license_note="Apache-2.0",
510
+ runtime="transformers",
511
+ status="active",
512
+ badge_relevance="off_the_grid",
513
+ notes="Specialized 0.9B document/receipt OCR model. Current SOTA for small OCR (June 2026). Loaded & verified: 1016M params on Apple Silicon.",
514
+ ),
515
+ # OCR / extraction
516
+ ModelEntry(
517
+ provider_group="ocr",
518
+ model_id="nuextract3-4b",
519
+ hf_model="nuance/NuExtract3-4B",
520
+ params_b=4.0,
521
+ license_note="CC-BY-NC-4.0",
522
+ runtime="transformers",
523
+ status="candidate",
524
+ notes="strong receipt extraction, non-commercial. Superseded by glm-ocr-0.9b (1B params, purpose-built for OCR).",
525
+ ),
526
+ ModelEntry(
527
+ provider_group="ocr",
528
+ model_id="deepseek-ocr-2",
529
+ hf_model="deepseek-ai/DeepSeek-OCR-2",
530
+ params_b=3.0,
531
+ license_note="MIT",
532
+ runtime="transformers",
533
+ status="candidate",
534
+ notes="HF frontier OCR candidate (2026). Exact receipt/label sweep pending; promoted to registry so Modal/HF jobs can benchmark it against GLM-OCR and Tesseract.",
535
+ ),
536
+ # OCR — PaddleOCR-VL-1.6 (May 2026, Apache-2.0, multilingual OCR, unblocks Hindi)
537
+ ModelEntry(
538
+ provider_group="ocr",
539
+ model_id="paddleocr-vl-1.6",
540
+ hf_model="PaddlePaddle/PaddleOCR-VL-1.6",
541
+ params_b=0.9,
542
+ license_note="Apache-2.0",
543
+ runtime="transformers",
544
+ status="candidate",
545
+ badge_relevance="llama_champion",
546
+ notes="PaddleOCR-VL-1.6 (May 2026, 67k downloads). Apache-2.0. Multilingual OCR (109 langs incl Hindi). Custom PaddlePaddle code. OCR bench in flight. May unblock the long-standing Hindi OCR gap.",
547
+ ),
548
+ # OCR — PaddleOCR-VL-1.6-GGUF (May 2026, GGUF for llama.cpp)
549
+ ModelEntry(
550
+ provider_group="ocr",
551
+ model_id="paddleocr-vl-1.6-gguf",
552
+ hf_model="PaddlePaddle/PaddleOCR-VL-1.6-GGUF",
553
+ params_b=0.9,
554
+ license_note="Apache-2.0",
555
+ runtime="gguf",
556
+ status="candidate",
557
+ notes="PaddleOCR-VL-1.6 GGUF (May 2026, 67k downloads). BYPASSES the PaddlePaddle Python dep blocker. Can run via llama.cpp on Apple Silicon + Modal. THIS is the path to local Hindi OCR.",
558
+ ),
559
+ # OCR — dots.ocr (Jul 2025, 260k downloads, MIT, most popular)
560
+ ModelEntry(
561
+ provider_group="ocr",
562
+ model_id="dots-ocr",
563
+ hf_model="rednote-hilab/dots.ocr",
564
+ params_b=3.0,
565
+ license_note="MIT",
566
+ runtime="transformers",
567
+ status="candidate",
568
+ notes="dots.ocr (Jul 2025, 260k downloads). Most popular modern OCR. Custom code. OCR bench in flight.",
569
+ ),
570
+ # Segmentation
571
+ ModelEntry(
572
+ provider_group="segmentation",
573
+ model_id="rmbg-1.4",
574
+ hf_model="briaai/RMBG-1.4",
575
+ params_b=0.3,
576
+ license_note="Apache-2.0",
577
+ runtime="transformers",
578
+ status="candidate",
579
+ notes="RMBG-1.4 (Jun 2024, 8M downloads). Background removal. Supplanted by BiRefNet on Modal seg bench (13-Jun-2026).",
580
+ ),
581
+ # Segmentation — BiRefNet (Jul 2024, 683k downloads, WINNER 13-Jun-2026)
582
+ ModelEntry(
583
+ provider_group="segmentation",
584
+ model_id="birefnet",
585
+ hf_model="ZhengPeng7/BiRefNet",
586
+ params_b=0.3,
587
+ license_note="MIT",
588
+ runtime="transformers",
589
+ status="active",
590
+ badge_relevance="llama_champion",
591
+ notes="BiRefNet (Jul 2024, 683k downloads). **Modal A10G seg bench WINNER (13-Jun-2026): IoU 0.8555, pixel acc 0.9699, 0.432s/image, 20 synthetic product images.** Provider wired as birefnet backend. Now the default segmentation provider. RMBG-2.0 was gated. RMBG-1.4 had all_tied_weights_keys issue with newer transformers. GSF-ai/Birefnet-General couldn't load.",
592
+ ),
593
+ ModelEntry(
594
+ provider_group="segmentation",
595
+ model_id="rmbg-2.0",
596
+ hf_model="briaai/RMBG-2.0",
597
+ params_b=0.2,
598
+ license_note="Apache-2.0",
599
+ runtime="transformers",
600
+ status="candidate",
601
+ notes="HF frontier segmentation candidate (2026). GATED — needs user approval at https://huggingface.co/briaai/RMBG-2.0. Once approved, expect ~5-10% IoU improvement over BiRefNet (2.0 is a generational upgrade).",
602
+ ),
603
+ # Embeddings — Nomic-Embed-Text-v1.5 (Aug 2024, 2.3M downloads, WINNER 13-Jun-2026)
604
+ ModelEntry(
605
+ provider_group="embeddings",
606
+ model_id="nomic-embed-text-v1.5",
607
+ hf_model="nomic-ai/nomic-embed-text-v1.5",
608
+ params_b=0.137,
609
+ license_note="Apache-2.0",
610
+ runtime="transformers",
611
+ status="active",
612
+ badge_relevance="llama_champion",
613
+ notes="Nomic-Embed-Text-v1.5 (Aug 2024, 2.3M downloads). **Modal A10G embed bench WINNER (13-Jun-2026): Top-1 58%, Top-3 90%, dim 768.** Apache-2.0 (more permissive than BGE-M3 MIT). Provider wired as nomic backend. Supplants BGE-M3 (48% top-1).",
614
+ ),
615
+ # Embeddings — BGE-M3 (Apr 2024, 28.7M downloads, was default, demoted)
616
+ ModelEntry(
617
+ provider_group="embeddings",
618
+ model_id="bge-m3",
619
+ hf_model="BAAI/bge-m3",
620
+ params_b=0.6,
621
+ license_note="MIT",
622
+ runtime="transformers",
623
+ status="candidate",
624
+ notes="BGE-M3 (Apr 2024, 28.7M downloads, most popular multilingual). Modal A10G embed bench: 48% top-1 (vs Nomic 58%). Demoted to candidate. Provider wired as bge_m3 backend (kept for compatibility).",
625
+ ),
626
+ # Embeddings — Qwen3-Embedding-0.6B (Jun 2025, 8.7M downloads, most popular Qwen)
627
+ ModelEntry(
628
+ provider_group="embeddings",
629
+ model_id="qwen3-embedding-0.6b",
630
+ hf_model="Qwen/Qwen3-Embedding-0.6B",
631
+ params_b=0.6,
632
+ license_note="Apache-2.0",
633
+ runtime="transformers",
634
+ status="candidate",
635
+ badge_relevance="llama_champion",
636
+ notes="Qwen3-Embedding-0.6B (Jun 2025, 8.7M downloads). Most popular Qwen embedding. **Modal A10G embed bench (13-Jun-2026): 50% top-1, 84% top-3, dim 1024.** Solid but loses to Nomic (58% top-1, smaller dim).",
637
+ ),
638
+ # Embeddings — Qwen3-Embedding-8B (Jun 2025, 1.9M downloads)
639
+ ModelEntry(
640
+ provider_group="embeddings",
641
+ model_id="qwen3-embedding-8b",
642
+ hf_model="Qwen/Qwen3-Embedding-8B",
643
+ params_b=8.0,
644
+ license_note="Apache-2.0",
645
+ runtime="transformers",
646
+ status="candidate",
647
+ notes="Qwen3-Embedding-8B (Jun 2025, 1.9M downloads). Higher-quality. Bench pending. Would consume most of the 32B cap alone.",
648
+ ),
649
+ # Embeddings — mxbai-embed-large-v1 (Mar 2024, 6M downloads)
650
+ ModelEntry(
651
+ provider_group="embeddings",
652
+ model_id="mxbai-embed-large",
653
+ hf_model="mixedbread-ai/mxbai-embed-large-v1",
654
+ params_b=0.335,
655
+ license_note="Apache-2.0",
656
+ runtime="transformers",
657
+ status="candidate",
658
+ notes="mxbai-embed-large-v1 (Mar 2024, 6M downloads). **Modal A10G embed bench (13-Jun-2026): 56% top-1, 82% top-3, dim 1024.** Strong but loses to Nomic (58% top-1, 90% top-3, smaller dim).",
659
+ ),
660
+ # Embeddings — Jina v5 text-nano (Jan 2026, 543k downloads, multimodal)
661
+ ModelEntry(
662
+ provider_group="embeddings",
663
+ model_id="jina-embeddings-v5-text-nano",
664
+ hf_model="jinaai/jina-embeddings-v5-text-nano",
665
+ params_b=0.2,
666
+ license_note="CC-BY-NC-4.0",
667
+ runtime="transformers",
668
+ status="candidate",
669
+ notes="Jina v5 text-nano (Jan 2026, 543k downloads). Multilingual MTEB. Embedding bench pending.",
670
+ ),
671
+ # Fine-tuned
672
+ ModelEntry(
673
+ provider_group="planner",
674
+ model_id="shopstack-parser-lora",
675
+ hf_model="",
676
+ params_b=0.0,
677
+ license_note="Apache-2.0 (planned)",
678
+ runtime="transformers",
679
+ status="candidate",
680
+ badge_relevance="well_tuned",
681
+ notes="future fine-tuned command parser",
682
+ ),
683
+ # Image generation
684
+ ModelEntry(
685
+ provider_group="image_edit",
686
+ model_id="flux.2-klein-4b",
687
+ hf_model="black-forest-labs/FLUX.2-klein-4B",
688
+ params_b=4.0,
689
+ license_note="FLUX.2-dev Non-Commercial",
690
+ runtime="diffusers",
691
+ status="active",
692
+ badge_relevance="llama_champion",
693
+ notes="visual card generation — FluxImageProvider wired as image_gen backend",
694
+ ),
695
+ ]
696
+
697
+
698
+ def get_registry(group: str | None = None) -> list[ModelEntry]:
699
+ if group:
700
+ return [m for m in MODEL_REGISTRY if m.provider_group == group]
701
+ return list(MODEL_REGISTRY)
702
+
703
+
704
+ def get_active(group: str) -> list[ModelEntry]:
705
+ return [m for m in MODEL_REGISTRY if m.provider_group == group and m.status == "active"]
706
+
707
+
708
+ def get_active_models() -> list[ModelEntry]:
709
+ return [m for m in MODEL_REGISTRY if m.status == "active"]
710
+
711
+
712
+ def total_active_params() -> float:
713
+ return sum(m.params_b for m in MODEL_REGISTRY if m.status == "active")
714
+
715
+
716
+ def total_candidate_only_params() -> float:
717
+ return total_candidate_params()
718
+
719
+
720
+ def total_loaded_params() -> float:
721
+ return total_active_params()
722
+
723
+
724
+ def total_candidate_params() -> float:
725
+ return sum(m.params_b for m in MODEL_REGISTRY if m.status == "candidate")
726
+
727
+
728
+ def total_selected_params(include_candidates: bool = False) -> float:
729
+ if include_candidates:
730
+ return sum(m.params_b for m in MODEL_REGISTRY if m.status in ("active", "candidate"))
731
+ return total_active_params()
732
+
733
+
734
+ def validate_active_model_budget(max_params_b: float = MAX_ACTIVE_MODEL_PARAMS_B) -> None:
735
+ total = total_loaded_params()
736
+ if total > max_params_b:
737
+ raise ValueError(
738
+ f"Active model stack is {total}B, which exceeds the {max_params_b}B cap"
739
+ )
740
+
741
+
742
+ def get_status_summary() -> dict[str, int]:
743
+ counts: dict[str, int] = {"active": 0, "candidate": 0, "deprecated": 0, "rejected": 0}
744
+ for model in MODEL_REGISTRY:
745
+ counts[model.status] += 1
746
+ return counts
shopstack/module_registry.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Module registry — single source of truth for ShopStack module metadata.
2
+
3
+ Each ShopStack module (ShopStock, ShopBasket, ShopCompare, etc.) registers its
4
+ name, slug, description, tab IDs, key service paths, and dependencies here.
5
+ Any UI surface that needs module metadata imports from this registry instead of
6
+ hardcoding strings.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class ModuleMetadata:
16
+ """Metadata for a single ShopStack module.
17
+
18
+ This is the canonical source for all module-level information.
19
+ Any UI surface that needs a module name, tab label, description,
20
+ or navigation order should read it from here — never hardcode it.
21
+
22
+ Attributes:
23
+ slug: Short machine-friendly identifier (e.g. \"stock\", \"basket\").
24
+ name: Human-readable module name (e.g. \"ShopStock\").
25
+ label: Short UI label for the module itself (e.g. \"My Stock\").
26
+ description: One-line summary of the module's purpose.
27
+ tab_ids: Gradio tab IDs associated with this module.
28
+ tab_labels: Canonical display labels keyed by tab_id.
29
+ If a tab_id is missing here, the module's ``label`` is used.
30
+ Example: tab_labels={"inventory": "Find Item at Home", "purchase": "Add Purchase"}
31
+ order: Display order in the UI navigation bar (lower = earlier).
32
+ service_modules: Python module paths providing this module's logic.
33
+ depends_on: Slugs of modules this module typically depends on.
34
+ is_source: Whether this module is a retailer/source adapter.
35
+ """
36
+ slug: str
37
+ name: str
38
+ label: str
39
+ description: str
40
+ tab_ids: tuple[str, ...] = ()
41
+ tab_labels: dict[str, str] = field(default_factory=dict)
42
+ order: int = 999
43
+ service_modules: tuple[str, ...] = ()
44
+ depends_on: tuple[str, ...] = ()
45
+ is_source: bool = False
46
+
47
+ def tab_label(self, tab_id: str) -> str:
48
+ """Return the canonical display label for a given tab_id.
49
+
50
+ Falls back to ``self.label`` if no per-tab label is registered.
51
+ """
52
+ return self.tab_labels.get(tab_id, self.label)
53
+
54
+
55
+ __all__ = [
56
+ "ModuleMetadata",
57
+ "SHOPSTOCK",
58
+ "SHOPBASKET",
59
+ "SHOPCOMPARE",
60
+ "SHOPLENS",
61
+ "SHOPMEMORY",
62
+ "SHOPAGENT",
63
+ "SOURCES",
64
+ "RUNTIME",
65
+ "SHOPNUTRITION",
66
+ "get_all",
67
+ "get_by_slug",
68
+ "get_by_tab_id",
69
+ "TAB_ORDER",
70
+ "TAB_LABELS",
71
+ "get_tab_ids",
72
+ "tab_order",
73
+ "tab_label",
74
+ "navigation",
75
+ "module_dependencies",
76
+ "summary_table",
77
+ ]
78
+
79
+
80
+ # ── Registry ─────────────────────────────────────────────────────────
81
+
82
+ _MODULES: dict[str, ModuleMetadata] = {}
83
+
84
+
85
+ def _register(m: ModuleMetadata) -> ModuleMetadata:
86
+ """Register a module and index it by slug."""
87
+ _MODULES[m.slug] = m
88
+ return m
89
+
90
+
91
+ # ── Tab order map ──────────────────────────────────────────────────
92
+
93
+ # This is the canonical ordering for the Gradio tab bar.
94
+ # Every tab that appears in the UI must have an entry here.
95
+ # Order = explicit integer; lower values appear first.
96
+ #
97
+ # Adding a new tab: add it here with the position where it should appear.
98
+ # The `order` field on each ModuleMetadata auto-aligns via the module's
99
+ # lowest-order tab, but this dict is the single source of truth for the
100
+ # actual tab bar sequence.
101
+ TAB_ORDER: dict[str, int] = {
102
+ "today": 10,
103
+ "cookbook": 20,
104
+ "basket": 30,
105
+ "market": 40,
106
+ "reconcile": 50,
107
+ "memory": 60,
108
+ }
109
+
110
+ # ── Tab display labels ────────────────────────────────────────────
111
+ # Canonical display names for every UI tab.
112
+ # A module's `tab_labels` dict overrides these for module-specific labels.
113
+ TAB_LABELS: dict[str, str] = {
114
+ "today": "Home",
115
+ "cookbook": "Recipes",
116
+ "basket": "Groceries",
117
+ "market": "While Shopping",
118
+ "reconcile": "At Home",
119
+ "memory": "Memory",
120
+ }
121
+
122
+
123
+ # ── Module definitions ───────────────────────────────────────────────
124
+ #
125
+ # Each module registers with:
126
+ # - slug: machine-friendly ID
127
+ # - name: canonical module name
128
+ # - label: short UI label for the module itself
129
+ # - tab_labels: per-tab display labels (override TAB_LABELS)
130
+ # - order: display priority (auto-derived from TAB_ORDER)
131
+ # - tab_ids: which Gradio tab IDs belong to this module
132
+ # - service_modules: Python paths implementing the module
133
+ # - depends_on: module slugs this module depends on
134
+
135
+ SHOPSTOCK = _register(ModuleMetadata(
136
+ slug="stock",
137
+ name="ShopStock",
138
+ label="At Home",
139
+ description="Inventory, pantry, fridge, expiry, low-stock, use-soon, and household storage.",
140
+ tab_ids=("reconcile",),
141
+ tab_labels={"reconcile": "At Home"},
142
+ order=TAB_ORDER.get("reconcile", 999),
143
+ service_modules=(
144
+ "shopstack.ui.screens.inventory",
145
+ "shopstack.ui.screens.portability",
146
+ "shopstack.portability",
147
+ ),
148
+ ))
149
+
150
+ SHOPBASKET = _register(ModuleMetadata(
151
+ slug="basket",
152
+ name="ShopBasket",
153
+ label="Groceries",
154
+ description="Shopping list creation, decision classification (buy/skip/use-soon), cart planning, and market basket optimization.",
155
+ tab_ids=("basket",),
156
+ tab_labels={"basket": "Groceries"},
157
+ order=TAB_ORDER.get("basket", 999),
158
+ service_modules=(
159
+ "shopstack.services.shopping",
160
+ "shopstack.ui.screens.shopping",
161
+ ),
162
+ depends_on=("stock",),
163
+ ))
164
+
165
+ SHOPCOMPARE = _register(ModuleMetadata(
166
+ slug="compare",
167
+ name="ShopCompare",
168
+ label="Price Check",
169
+ description="Retailer price comparison, unit price normalization, price-drop alerts, and best-store recommendations.",
170
+ tab_ids=("basket",),
171
+ tab_labels={"basket": "Groceries"},
172
+ order=TAB_ORDER.get("basket", 999),
173
+ service_modules=(
174
+ "shopstack.market",
175
+ "shopstack.market.analytics",
176
+ "shopstack.market.normalization",
177
+ ),
178
+ depends_on=("sources",),
179
+ ))
180
+
181
+ SHOPLENS = _register(ModuleMetadata(
182
+ slug="lens",
183
+ name="ShopLens",
184
+ label="While Shopping",
185
+ description="Scanning and import: barcode, photo, receipt, object detection, OCR, and voice input.",
186
+ tab_ids=("market",),
187
+ tab_labels={"market": "While Shopping"},
188
+ order=TAB_ORDER.get("market", 999),
189
+ service_modules=(
190
+ "shopstack.services.market_lens",
191
+ "shopstack.ui.screens.market_lens",
192
+ "shopstack.scanner",
193
+ ),
194
+ depends_on=("stock",),
195
+ ))
196
+
197
+ SHOPMEMORY = _register(ModuleMetadata(
198
+ slug="memory",
199
+ name="ShopMemory",
200
+ label="Memory",
201
+ description="Price history, household preferences, field notes, purchase cadence, and waste pattern tracking.",
202
+ tab_ids=("basket", "memory"),
203
+ tab_labels={
204
+ "basket": "Groceries",
205
+ "memory": "Memory",
206
+ },
207
+ order=TAB_ORDER.get("memory", 999),
208
+ service_modules=(
209
+ "shopstack.ui.views",
210
+ "shopstack.ui.screens.other",
211
+ ),
212
+ ))
213
+
214
+ SHOPAGENT = _register(ModuleMetadata(
215
+ slug="agent",
216
+ name="ShopAgent",
217
+ label="Ask ShopStack",
218
+ description="Reasoning layer: AI planner with tool-calling, decision classification, and trace audit trail.",
219
+ tab_ids=("today", "memory"),
220
+ tab_labels={
221
+ "today": "Home",
222
+ "memory": "Memory",
223
+ },
224
+ order=TAB_ORDER.get("today", 999),
225
+ service_modules=(
226
+ "shopstack.planner.engine",
227
+ "shopstack.planner.prompts",
228
+ "shopstack.planner.parser",
229
+ "shopstack.decisions",
230
+ "shopstack.traces.export",
231
+ ),
232
+ depends_on=("stock", "basket", "memory"),
233
+ ))
234
+
235
+ SOURCES = _register(ModuleMetadata(
236
+ slug="sources",
237
+ name="Sources",
238
+ label="Sources",
239
+ description="Retailer dataset adapters for price intelligence and basket comparison. Includes Swiggy Instamart.",
240
+ tab_ids=(),
241
+ order=999,
242
+ service_modules=(
243
+ "shopstack.market.sources.swiggy",
244
+ ),
245
+ is_source=True,
246
+ ))
247
+
248
+ RUNTIME = _register(ModuleMetadata(
249
+ slug="runtime",
250
+ name="Runtime",
251
+ label="System",
252
+ description="Provider/model runtime diagnostics, budget status, and candidate model catalog. Developer-facing.",
253
+ tab_ids=("memory",),
254
+ tab_labels={"memory": "System"},
255
+ order=TAB_ORDER.get("memory", 999),
256
+ service_modules=(
257
+ "shopstack.ui.screens.model_stack",
258
+ "shopstack.model_registry",
259
+ "shopstack.providers.runtime",
260
+ ),
261
+ ))
262
+
263
+ SHOPNUTRITION = _register(ModuleMetadata(
264
+ slug="nutrition",
265
+ name="ShopNutrition",
266
+ label="Nutrition",
267
+ description="Nutrition lookup for common Indian household items and kitchen macro breakdown from inventory.",
268
+ tab_ids=("memory",),
269
+ tab_labels={"memory": "Memory"},
270
+ order=TAB_ORDER.get("memory", 999),
271
+ service_modules=(
272
+ "shopstack.services.nutrition",
273
+ "shopstack.ui.screens.nutrition",
274
+ ),
275
+ depends_on=("stock",),
276
+ ))
277
+
278
+
279
+ # ── Lookup helpers ───────────────────────────────────────────────────
280
+
281
+ def get_all() -> list[ModuleMetadata]:
282
+ """Return all registered modules, ordered by definition."""
283
+ return list(_MODULES.values())
284
+
285
+
286
+ def tab_order() -> list[tuple[str, str]]:
287
+ """Return (tab_id, display_label) pairs in UI navigation order.
288
+
289
+ This is the canonical source for building the Gradio tab bar in app.py.
290
+ Every registered tab appears once, ordered by ``TAB_ORDER``.
291
+ Unknown tab_ids (not in TAB_ORDER) are sorted last alphabetically.
292
+ """
293
+ known = [(TAB_ORDER[tid], tid, TAB_LABELS.get(tid, tid)) for tid in TAB_ORDER]
294
+ known.sort(key=lambda x: x[0])
295
+ return [(tid, label) for _, tid, label in known]
296
+
297
+
298
+ def tab_label(tab_id: str) -> str:
299
+ """Return the canonical display label for a tab ID.
300
+
301
+ Checks per-module tab_labels first (for module-specific overrides),
302
+ then the global TAB_LABELS, then falls back to the raw tab_id.
303
+ """
304
+ # Check if any module has a per-tab override
305
+ for m in _MODULES.values():
306
+ if tab_id in m.tab_labels:
307
+ return m.tab_labels[tab_id]
308
+ return TAB_LABELS.get(tab_id, tab_id)
309
+
310
+
311
+ def get_by_slug(slug: str) -> ModuleMetadata | None:
312
+ """Look up a module by its slug (e.g. \"stock\", \"basket\")."""
313
+ return _MODULES.get(slug)
314
+
315
+
316
+ def get_by_tab_id(tab_id: str) -> list[ModuleMetadata]:
317
+ """Find all modules associated with a given Gradio tab ID.
318
+
319
+ A single tab may belong to multiple modules (e.g. \"prices\" belongs to
320
+ both ShopMemory and ShopCompare).
321
+ """
322
+ return [m for m in _MODULES.values() if tab_id in m.tab_ids]
323
+
324
+
325
+ def get_tab_ids(slug: str) -> tuple[str, ...]:
326
+ """Return all tab IDs for a given module slug."""
327
+ m = _MODULES.get(slug)
328
+ return m.tab_ids if m else ()
329
+
330
+
331
+ def navigation() -> list[tuple[str, str, str]]:
332
+ """Return an ordered list of (tab_id, label, module_name) for navigation.
333
+
334
+ This is the canonical source for building tab navigation in app.py and
335
+ other UI surfaces. Only modules with at least one tab ID are included.
336
+ """
337
+ entries: list[tuple[str, str, str]] = []
338
+ for m in _MODULES.values():
339
+ for tid in m.tab_ids:
340
+ entries.append((tid, m.label, m.name))
341
+ return entries
342
+
343
+
344
+ def module_dependencies(slug: str) -> list[ModuleMetadata]:
345
+ """Return the ModuleMetadata objects this module depends on."""
346
+ m = _MODULES.get(slug)
347
+ if not m:
348
+ return []
349
+ return [dep for dep_slug in m.depends_on if (dep := _MODULES.get(dep_slug))]
350
+
351
+
352
+ def summary_table() -> list[dict[str, str]]:
353
+ """Return a table-friendly list of dicts for display/export."""
354
+ return [
355
+ {
356
+ "Module": m.name,
357
+ "Label": m.label,
358
+ "Description": m.description,
359
+ "Tabs": ", ".join(m.tab_ids) if m.tab_ids else "(none)",
360
+ }
361
+ for m in _MODULES.values()
362
+ ]
shopstack/persistence/__init__.py ADDED
File without changes
shopstack/persistence/database.py ADDED
@@ -0,0 +1,1764 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ import threading
6
+ from datetime import date, datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from shopstack.config import settings
11
+ from shopstack.schemas.models import (
12
+ InventoryEvent,
13
+ InventoryLot,
14
+ HouseholdLocation,
15
+ MovementEvent,
16
+ new_id,
17
+ PriceObservation,
18
+ PurchaseEvent,
19
+ ShoppingList,
20
+ ShoppingListItem,
21
+ Store,
22
+ Trace,
23
+ ReconciliationEvent,
24
+ PreferenceSignal,
25
+ )
26
+
27
+
28
+ class Database:
29
+ def __init__(self, db_path: str | None = None):
30
+ self.db_path = db_path if db_path is not None else settings.db_path
31
+ Path(self.db_path).parent.mkdir(parents=True, exist_ok=True)
32
+ # Per-thread sqlite3 connections. ``sqlite3.Connection`` is not
33
+ # safe to share across threads — even with ``check_same_thread=
34
+ # False`` concurrent ``execute`` + ``commit`` from anyio worker
35
+ # threads corrupts cursor state and surfaces as
36
+ # ``InterfaceError: bad parameter or other API misuse`` or
37
+ # ``NoneType`` from ``fetchone()`` deep in the call stack.
38
+ # Each thread opens its own connection to the same file;
39
+ # WAL mode serialises writes across them.
40
+ self._local = threading.local()
41
+ self._init_lock = threading.Lock()
42
+ self._init_db()
43
+
44
+ @property
45
+ def conn(self) -> sqlite3.Connection:
46
+ c = getattr(self._local, "conn", None)
47
+ if c is None:
48
+ c = sqlite3.connect(self.db_path, check_same_thread=True)
49
+ c.row_factory = sqlite3.Row
50
+ c.execute("PRAGMA journal_mode=WAL")
51
+ c.execute("PRAGMA foreign_keys=ON")
52
+ self._local.conn = c
53
+ return c
54
+
55
+ def _init_db(self) -> None:
56
+ c = self.conn
57
+ c.executescript("""
58
+ CREATE TABLE IF NOT EXISTS inventory_lots (
59
+ lot_id TEXT PRIMARY KEY,
60
+ canonical_name TEXT NOT NULL,
61
+ display_name TEXT NOT NULL,
62
+ category TEXT DEFAULT '',
63
+ quantity REAL DEFAULT 1.0,
64
+ unit TEXT DEFAULT 'unit',
65
+ storage_location_id TEXT DEFAULT '',
66
+ purchase_date TEXT,
67
+ estimated_use_by_date TEXT,
68
+ label_expiry_date TEXT,
69
+ opened_date TEXT,
70
+ price_paid REAL,
71
+ currency TEXT DEFAULT 'INR',
72
+ source_event_id TEXT DEFAULT '',
73
+ confidence REAL DEFAULT 1.0,
74
+ image_crop_path TEXT,
75
+ status TEXT DEFAULT 'active',
76
+ created_at TEXT,
77
+ updated_at TEXT,
78
+ user_id TEXT DEFAULT ''
79
+ );
80
+
81
+ CREATE TABLE IF NOT EXISTS purchase_events (
82
+ event_id TEXT PRIMARY KEY,
83
+ timestamp TEXT,
84
+ canonical_name TEXT DEFAULT '',
85
+ quantity REAL DEFAULT 1.0,
86
+ unit TEXT DEFAULT 'unit',
87
+ total_price REAL DEFAULT 0.0,
88
+ currency TEXT DEFAULT 'INR',
89
+ source_type TEXT DEFAULT 'manual',
90
+ store_name TEXT,
91
+ raw_text TEXT,
92
+ source_file_path TEXT,
93
+ confirmed INTEGER DEFAULT 0,
94
+ user_id TEXT DEFAULT ''
95
+ );
96
+
97
+ CREATE TABLE IF NOT EXISTS shopping_lists (
98
+ list_id TEXT PRIMARY KEY,
99
+ name TEXT DEFAULT 'Shopping List',
100
+ created_at TEXT,
101
+ updated_at TEXT,
102
+ goal TEXT DEFAULT '',
103
+ is_active INTEGER DEFAULT 1,
104
+ user_id TEXT DEFAULT ''
105
+ );
106
+
107
+ CREATE TABLE IF NOT EXISTS shopping_list_items (
108
+ item_id TEXT PRIMARY KEY,
109
+ list_id TEXT NOT NULL,
110
+ canonical_name TEXT NOT NULL,
111
+ requested_quantity REAL,
112
+ unit TEXT,
113
+ priority TEXT DEFAULT 'optional',
114
+ reason TEXT DEFAULT '',
115
+ status TEXT DEFAULT 'pending',
116
+ linked_lots TEXT DEFAULT '[]',
117
+ FOREIGN KEY (list_id) REFERENCES shopping_lists(list_id)
118
+ );
119
+
120
+ CREATE TABLE IF NOT EXISTS household_locations (
121
+ location_id TEXT PRIMARY KEY,
122
+ name TEXT NOT NULL,
123
+ parent_location_id TEXT,
124
+ location_type TEXT DEFAULT 'shelf',
125
+ photo_path TEXT,
126
+ notes TEXT
127
+ );
128
+
129
+ CREATE TABLE IF NOT EXISTS movement_events (
130
+ movement_id TEXT PRIMARY KEY,
131
+ lot_id TEXT NOT NULL,
132
+ from_location_id TEXT,
133
+ to_location_id TEXT NOT NULL,
134
+ timestamp TEXT,
135
+ source TEXT DEFAULT 'manual',
136
+ confidence REAL DEFAULT 1.0,
137
+ FOREIGN KEY (lot_id) REFERENCES inventory_lots(lot_id)
138
+ );
139
+
140
+ CREATE TABLE IF NOT EXISTS price_observations (
141
+ price_id TEXT PRIMARY KEY,
142
+ canonical_name TEXT NOT NULL,
143
+ quantity REAL DEFAULT 1.0,
144
+ unit TEXT DEFAULT 'unit',
145
+ price REAL NOT NULL,
146
+ currency TEXT DEFAULT 'INR',
147
+ store_name TEXT,
148
+ store_id TEXT,
149
+ observation_date TEXT,
150
+ source_event_id TEXT DEFAULT '',
151
+ notes TEXT,
152
+ user_id TEXT DEFAULT ''
153
+ );
154
+
155
+ CREATE TABLE IF NOT EXISTS stores (
156
+ store_id TEXT PRIMARY KEY,
157
+ name TEXT NOT NULL,
158
+ location TEXT,
159
+ store_type TEXT DEFAULT 'kirana',
160
+ notes TEXT
161
+ );
162
+
163
+ CREATE TABLE IF NOT EXISTS traces (
164
+ trace_id TEXT PRIMARY KEY,
165
+ input_type TEXT DEFAULT '',
166
+ user_goal TEXT DEFAULT '',
167
+ redacted_user_request TEXT DEFAULT '',
168
+ perception TEXT DEFAULT '{}',
169
+ inventory_context TEXT DEFAULT '{}',
170
+ decision TEXT DEFAULT '{}',
171
+ proposed_tool_calls TEXT DEFAULT '[]',
172
+ human_confirmation TEXT,
173
+ final_response TEXT DEFAULT '',
174
+ timestamp TEXT,
175
+ user_id TEXT DEFAULT ''
176
+ );
177
+
178
+ CREATE TABLE IF NOT EXISTS app_config (
179
+ key TEXT PRIMARY KEY,
180
+ value TEXT NOT NULL
181
+ );
182
+
183
+ CREATE TABLE IF NOT EXISTS market_snapshots (
184
+ snapshot_id TEXT PRIMARY KEY,
185
+ source TEXT NOT NULL,
186
+ source_category TEXT NOT NULL,
187
+ captured_at TEXT NOT NULL,
188
+ record_count INTEGER DEFAULT 0,
189
+ analytics TEXT DEFAULT '{}',
190
+ freshness_context TEXT DEFAULT 'unknown',
191
+ stored_at TEXT
192
+ );
193
+
194
+ CREATE TABLE IF NOT EXISTS market_records (
195
+ record_id TEXT PRIMARY KEY,
196
+ snapshot_id TEXT NOT NULL,
197
+ raw_name TEXT NOT NULL,
198
+ canonical_name TEXT NOT NULL,
199
+ description TEXT DEFAULT '',
200
+ raw_size TEXT DEFAULT '',
201
+ normalized_quantity REAL,
202
+ normalized_unit TEXT,
203
+ package_count INTEGER DEFAULT 1,
204
+ is_combo INTEGER DEFAULT 0,
205
+ is_weight_based INTEGER DEFAULT 0,
206
+ is_piece_based INTEGER DEFAULT 0,
207
+ is_size_class INTEGER DEFAULT 0,
208
+ size_class TEXT DEFAULT '',
209
+ price_inr REAL DEFAULT 0.0,
210
+ mrp_inr REAL DEFAULT 0.0,
211
+ discount_percent_displayed REAL DEFAULT 0.0,
212
+ discount_amount_inr REAL DEFAULT 0.0,
213
+ computed_discount_percent REAL DEFAULT 0.0,
214
+ availability TEXT DEFAULT '',
215
+ is_available INTEGER DEFAULT 1,
216
+ tag TEXT DEFAULT '',
217
+ is_ad INTEGER DEFAULT 0,
218
+ is_upgrade INTEGER DEFAULT 0,
219
+ card_index INTEGER DEFAULT 0,
220
+ delivery_time TEXT DEFAULT '',
221
+ price_per_kg REAL,
222
+ price_per_100g REAL,
223
+ price_per_piece REAL,
224
+ normalization_warnings TEXT DEFAULT '',
225
+ variety TEXT DEFAULT '',
226
+ brand TEXT DEFAULT '',
227
+ FOREIGN KEY (snapshot_id) REFERENCES market_snapshots(snapshot_id)
228
+ );
229
+
230
+ CREATE TABLE IF NOT EXISTS market_record_components (
231
+ component_id TEXT PRIMARY KEY,
232
+ record_id TEXT NOT NULL,
233
+ component_name TEXT NOT NULL,
234
+ FOREIGN KEY (record_id) REFERENCES market_records(record_id)
235
+ );
236
+
237
+ CREATE TABLE IF NOT EXISTS reconciliation_events (
238
+ event_id TEXT PRIMARY KEY,
239
+ timestamp TEXT,
240
+ canonical_name TEXT NOT NULL,
241
+ planned_action TEXT NOT NULL,
242
+ actual_action TEXT NOT NULL,
243
+ quantity REAL DEFAULT 0.0,
244
+ unit TEXT DEFAULT 'unit',
245
+ price_paid REAL,
246
+ planned_price REAL,
247
+ substituted_with TEXT,
248
+ notes TEXT,
249
+ source TEXT DEFAULT 'manual',
250
+ user_id TEXT DEFAULT ''
251
+ );
252
+
253
+ CREATE TABLE IF NOT EXISTS preference_signals (
254
+ signal_id TEXT PRIMARY KEY,
255
+ canonical_name TEXT NOT NULL,
256
+ signal_type TEXT NOT NULL,
257
+ value TEXT NOT NULL,
258
+ confidence REAL DEFAULT 0.5,
259
+ source TEXT DEFAULT 'observed',
260
+ created_at TEXT,
261
+ updated_at TEXT,
262
+ user_id TEXT DEFAULT ''
263
+ );
264
+
265
+ CREATE TABLE IF NOT EXISTS inventory_events (
266
+ event_id TEXT PRIMARY KEY,
267
+ timestamp TEXT NOT NULL,
268
+ lot_id TEXT DEFAULT '',
269
+ canonical_name TEXT DEFAULT '',
270
+ action TEXT NOT NULL,
271
+ quantity_before REAL,
272
+ quantity_after REAL,
273
+ quantity_delta REAL,
274
+ unit TEXT DEFAULT '',
275
+ location_from TEXT,
276
+ location_to TEXT,
277
+ source TEXT DEFAULT 'manual',
278
+ notes TEXT,
279
+ user_id TEXT DEFAULT ''
280
+ );
281
+
282
+ CREATE TABLE IF NOT EXISTS households (
283
+ household_id TEXT PRIMARY KEY,
284
+ name TEXT NOT NULL,
285
+ created_at TEXT NOT NULL,
286
+ updated_at TEXT NOT NULL,
287
+ notes TEXT DEFAULT ''
288
+ );
289
+
290
+ -- ── Phase 10: household_members (multi-household permissioning) ──
291
+ -- A user is *in* zero or more households with a role.
292
+ -- One row per (household_id, user_id) pair. Composite
293
+ -- primary key prevents duplicate memberships.
294
+ CREATE TABLE IF NOT EXISTS household_members (
295
+ household_id TEXT NOT NULL,
296
+ user_id TEXT NOT NULL,
297
+ role TEXT NOT NULL DEFAULT 'member',
298
+ joined_at TEXT NOT NULL,
299
+ PRIMARY KEY (household_id, user_id),
300
+ FOREIGN KEY (household_id) REFERENCES households(household_id) ON DELETE CASCADE
301
+ );
302
+ CREATE INDEX IF NOT EXISTS idx_household_members_user
303
+ ON household_members(user_id);
304
+
305
+ CREATE VIEW IF NOT EXISTS price_history AS
306
+ SELECT * FROM price_observations;
307
+
308
+ CREATE TRIGGER IF NOT EXISTS price_history_delete
309
+ INSTEAD OF DELETE ON price_history
310
+ BEGIN
311
+ DELETE FROM price_observations WHERE price_id = OLD.price_id;
312
+ END;
313
+
314
+ CREATE VIEW IF NOT EXISTS agent_traces AS
315
+ SELECT * FROM traces;
316
+
317
+ CREATE TRIGGER IF NOT EXISTS agent_traces_delete
318
+ INSTEAD OF DELETE ON agent_traces
319
+ BEGIN
320
+ DELETE FROM traces WHERE trace_id = OLD.trace_id;
321
+ END;
322
+
323
+ -- ── Object Trail: negative memory (places where items are confirmed NOT to be) ──
324
+ CREATE TABLE IF NOT EXISTS negative_memory (
325
+ memory_id TEXT PRIMARY KEY,
326
+ lot_id TEXT NOT NULL,
327
+ location_id TEXT NOT NULL,
328
+ location_name TEXT DEFAULT '',
329
+ confirmed_at TEXT NOT NULL,
330
+ source TEXT DEFAULT 'user_feedback',
331
+ confidence REAL DEFAULT 1.0,
332
+ user_id TEXT DEFAULT ''
333
+ );
334
+ CREATE INDEX IF NOT EXISTS idx_negative_memory_lot
335
+ ON negative_memory(lot_id);
336
+
337
+ -- ── Object Trail: person associations (who owns/uses an item) ──
338
+ CREATE TABLE IF NOT EXISTS person_associations (
339
+ association_id TEXT PRIMARY KEY,
340
+ lot_id TEXT NOT NULL,
341
+ person_id TEXT NOT NULL,
342
+ person_name TEXT NOT NULL,
343
+ relationship TEXT DEFAULT 'owner',
344
+ confidence REAL DEFAULT 1.0,
345
+ user_id TEXT DEFAULT ''
346
+ );
347
+ CREATE INDEX IF NOT EXISTS idx_person_associations_lot
348
+ ON person_associations(lot_id);
349
+ """)
350
+ self._migrate_market_snapshot_schema()
351
+ self._migrate_add_user_scoping()
352
+ self._migrate_backfill_household_owners()
353
+ self._seed_locations()
354
+ self._seed_default_household()
355
+ self._apply_trace_retention_policy()
356
+ self.conn.commit()
357
+
358
+ def _migrate_market_snapshot_schema(self) -> None:
359
+ rows = self.conn.execute("PRAGMA table_info(market_snapshots)").fetchall()
360
+ existing_cols = {r["name"] for r in rows}
361
+ migrations: list[tuple[str, str]] = [
362
+ ("record_count", "INTEGER DEFAULT 0"),
363
+ ("analytics", "TEXT DEFAULT '{}'"),
364
+ ("freshness_context", "TEXT DEFAULT 'unknown'"),
365
+ ("stored_at", "TEXT"),
366
+ ]
367
+ for col_name, decl in migrations:
368
+ if col_name not in existing_cols:
369
+ self.conn.execute(f"ALTER TABLE market_snapshots ADD COLUMN {col_name} {decl}")
370
+
371
+ def _migrate_add_user_scoping(self) -> None:
372
+ tables = ["inventory_lots", "purchase_events", "shopping_lists", "traces", "price_observations"]
373
+ for table in tables:
374
+ try:
375
+ self.conn.execute(f"ALTER TABLE {table} ADD COLUMN user_id TEXT DEFAULT ''")
376
+ except sqlite3.OperationalError as exc:
377
+ message = str(exc).lower()
378
+ if "duplicate column name" in message or "already exists" in message:
379
+ continue
380
+ raise
381
+
382
+ def _migrate_backfill_household_owners(self) -> None:
383
+ """Backfill owner memberships for any pre-existing households.
384
+
385
+ For each household that exists in the ``households``
386
+ table but has no members, add the default user as
387
+ the owner. This keeps the migration idempotent: running
388
+ on a fresh install is a no-op (the seeder does it);
389
+ running on an existing install backfills the gap.
390
+ """
391
+ from datetime import datetime
392
+ now = datetime.now().isoformat()
393
+ rows = self.conn.execute(
394
+ "SELECT h.household_id FROM households h "
395
+ "WHERE NOT EXISTS (SELECT 1 FROM household_members m "
396
+ "WHERE m.household_id = h.household_id)"
397
+ ).fetchall()
398
+ for row in rows:
399
+ hid = row["household_id"]
400
+ self.conn.execute(
401
+ "INSERT OR IGNORE INTO household_members "
402
+ "(household_id, user_id, role, joined_at) "
403
+ "VALUES (?, ?, ?, ?)",
404
+ (hid, hid, "owner", now),
405
+ )
406
+ if rows:
407
+ self.conn.commit()
408
+
409
+ # ── Active household tracking ────────────────────────────────
410
+
411
+ @property
412
+ def active_household_id(self) -> str:
413
+ """Get the currently active household ID, or default if none set.
414
+
415
+ Returns ``""`` when explicitly set to empty (disabling user_id
416
+ filtering), or ``settings.default_household_user_id`` when no
417
+ household has ever been selected.
418
+ """
419
+ stored = self.get_config_value("active_household_id", "")
420
+ # If the config key exists at all (including with empty value), return its value.
421
+ # This allows callers to opt out of household scoping by setting to "".
422
+ has_key = self.conn.execute(
423
+ "SELECT COUNT(*) FROM app_config WHERE key = 'active_household_id'"
424
+ ).fetchone()[0]
425
+ if has_key:
426
+ return stored
427
+ return settings.default_household_user_id
428
+
429
+ @active_household_id.setter
430
+ def active_household_id(self, household_id: str) -> None:
431
+ self.set_config_value("active_household_id", household_id)
432
+
433
+ # ── Household CRUD ────────────────────────────────────────────
434
+
435
+ def list_households(self) -> list[dict[str, str]]:
436
+ """List all registered households with their IDs and names."""
437
+ rows = self.conn.execute(
438
+ "SELECT household_id, name, created_at, notes FROM households ORDER BY created_at ASC"
439
+ ).fetchall()
440
+ return [dict(r) for r in rows]
441
+
442
+ def add_household(self, household_id: str, name: str, notes: str = "") -> bool:
443
+ """Register a new household. Returns True if created, False if already exists."""
444
+ from datetime import datetime
445
+ now = datetime.now().isoformat()
446
+ try:
447
+ self.conn.execute(
448
+ "INSERT INTO households (household_id, name, created_at, updated_at, notes) VALUES (?, ?, ?, ?, ?)",
449
+ (household_id, name, now, now, notes),
450
+ )
451
+ self.conn.commit()
452
+ return True
453
+ except Exception:
454
+ return False
455
+
456
+ def remove_household(self, household_id: str) -> bool:
457
+ """Remove a household registration."""
458
+ try:
459
+ self.conn.execute(
460
+ "DELETE FROM households WHERE household_id = ?", (household_id,)
461
+ )
462
+ self.conn.commit()
463
+ return True
464
+ except Exception:
465
+ return False
466
+
467
+ # ── Household members (Phase 10 #1) ──────────────────────────
468
+
469
+ def list_household_members(self, household_id: str) -> list[dict[str, str]]:
470
+ """Return all members of a household, oldest first.
471
+
472
+ Each dict: ``{"household_id", "user_id", "role", "joined_at"}``.
473
+ Empty list when the household has no members.
474
+ """
475
+ rows = self.conn.execute(
476
+ "SELECT household_id, user_id, role, joined_at "
477
+ "FROM household_members WHERE household_id = ? "
478
+ "ORDER BY joined_at ASC",
479
+ (household_id,),
480
+ ).fetchall()
481
+ return [dict(r) for r in rows]
482
+
483
+ def list_households_for_user(self, user_id: str) -> list[dict[str, str]]:
484
+ """Return all households a user is a member of.
485
+
486
+ Each dict: ``{"household_id", "name", "role", "joined_at"}``.
487
+ Empty list when the user is in no households.
488
+ """
489
+ rows = self.conn.execute(
490
+ "SELECT h.household_id, h.name, m.role, m.joined_at "
491
+ "FROM households h JOIN household_members m "
492
+ "ON h.household_id = m.household_id "
493
+ "WHERE m.user_id = ? "
494
+ "ORDER BY m.joined_at ASC",
495
+ (user_id,),
496
+ ).fetchall()
497
+ return [dict(r) for r in rows]
498
+
499
+ def get_household_member(
500
+ self, household_id: str, user_id: str
501
+ ) -> dict[str, str] | None:
502
+ """Return the membership row for (household, user), or None."""
503
+ row = self.conn.execute(
504
+ "SELECT household_id, user_id, role, joined_at "
505
+ "FROM household_members "
506
+ "WHERE household_id = ? AND user_id = ?",
507
+ (household_id, user_id),
508
+ ).fetchone()
509
+ return dict(row) if row else None
510
+
511
+ def add_household_member(
512
+ self, household_id: str, user_id: str, role: str = "member"
513
+ ) -> bool:
514
+ """Add ``user_id`` to ``household_id`` with the given role.
515
+
516
+ Roles: ``"owner"`` (full control), ``"member"`` (read+write),
517
+ ``"guest"`` (read-only). Returns True if added, False if
518
+ already a member or the household doesn't exist.
519
+ """
520
+ from datetime import datetime
521
+ if role not in ("owner", "member", "guest"):
522
+ return False
523
+ # Verify household exists
524
+ exists = self.conn.execute(
525
+ "SELECT 1 FROM households WHERE household_id = ?", (household_id,)
526
+ ).fetchone()
527
+ if not exists:
528
+ return False
529
+ try:
530
+ self.conn.execute(
531
+ "INSERT INTO household_members (household_id, user_id, role, joined_at) "
532
+ "VALUES (?, ?, ?, ?)",
533
+ (household_id, user_id, role, datetime.now().isoformat()),
534
+ )
535
+ self.conn.commit()
536
+ return True
537
+ except Exception:
538
+ return False
539
+
540
+ def remove_household_member(self, household_id: str, user_id: str) -> bool:
541
+ """Remove ``user_id`` from ``household_id``.
542
+
543
+ Refuses to remove the last owner. Returns True on
544
+ success, False if the user wasn't a member or
545
+ removing them would orphan the household.
546
+ """
547
+ # Disallow removing the last owner
548
+ if self.get_household_member(household_id, user_id) is None:
549
+ return False
550
+ if self._is_last_owner(household_id, user_id):
551
+ return False
552
+ try:
553
+ self.conn.execute(
554
+ "DELETE FROM household_members "
555
+ "WHERE household_id = ? AND user_id = ?",
556
+ (household_id, user_id),
557
+ )
558
+ self.conn.commit()
559
+ return True
560
+ except Exception:
561
+ return False
562
+
563
+ def update_household_member_role(
564
+ self, household_id: str, user_id: str, new_role: str
565
+ ) -> bool:
566
+ """Change ``user_id``'s role in ``household_id``.
567
+
568
+ Refuses to demote the last owner. Returns True on
569
+ success, False if the role is invalid or the demotion
570
+ would orphan the household.
571
+ """
572
+ if new_role not in ("owner", "member", "guest"):
573
+ return False
574
+ if self.get_household_member(household_id, user_id) is None:
575
+ return False
576
+ if new_role != "owner" and self._is_last_owner(household_id, user_id):
577
+ return False
578
+ try:
579
+ self.conn.execute(
580
+ "UPDATE household_members SET role = ? "
581
+ "WHERE household_id = ? AND user_id = ?",
582
+ (new_role, household_id, user_id),
583
+ )
584
+ self.conn.commit()
585
+ return True
586
+ except Exception:
587
+ return False
588
+
589
+ def _is_last_owner(self, household_id: str, user_id: str) -> bool:
590
+ """True if ``user_id`` is the only owner of ``household_id``."""
591
+ member = self.get_household_member(household_id, user_id)
592
+ if not member or member.get("role") != "owner":
593
+ return False
594
+ rows = self.conn.execute(
595
+ "SELECT user_id FROM household_members "
596
+ "WHERE household_id = ? AND role = 'owner'",
597
+ (household_id,),
598
+ ).fetchall()
599
+ return len(rows) == 1 and rows[0]["user_id"] == user_id
600
+
601
+ def _seed_default_household(self) -> None:
602
+ """Ensure the default household + owner member exist.
603
+
604
+ Phase 10 #1: when the default household is created, we
605
+ also add the default user_id as its owner. This means
606
+ every existing user (and every fresh install) has at
607
+ least one household they own — no permission denials
608
+ on first run.
609
+ """
610
+ household_id = settings.default_household_user_id
611
+ existing = self.conn.execute(
612
+ "SELECT COUNT(*) FROM households WHERE household_id = ?", (household_id,)
613
+ ).fetchone()[0]
614
+ if existing == 0:
615
+ now = datetime.now().isoformat()
616
+ self.conn.execute(
617
+ "INSERT INTO households (household_id, name, created_at, updated_at, notes) VALUES (?, ?, ?, ?, ?)",
618
+ (household_id, "Default Household", now, now,
619
+ "Default household created automatically. Use the household switcher to add more."),
620
+ )
621
+ # Always ensure the default user is at least a member
622
+ # of the default household (idempotent — INSERT OR IGNORE).
623
+ # The ``datetime`` reference below relies on the module-level
624
+ # import (line 6) — a local import in the ``if`` block above
625
+ # would shadow it and cause an UnboundLocalError on the second
626
+ # call (when ``existing > 0`` skips the import).
627
+ self.conn.execute(
628
+ "INSERT OR IGNORE INTO household_members "
629
+ "(household_id, user_id, role, joined_at) VALUES (?, ?, ?, ?)",
630
+ (household_id, household_id, "owner", datetime.now().isoformat()),
631
+ )
632
+ # Mark as active
633
+ self.set_config_value("active_household_id", household_id)
634
+ self.conn.commit()
635
+
636
+ def _seed_locations(self) -> None:
637
+ existing = self.conn.execute("SELECT COUNT(*) FROM household_locations").fetchone()[0]
638
+ if existing > 0:
639
+ return
640
+ locations = [
641
+ ("home", "Home", None, "room"),
642
+ ("kitchen", "Kitchen", "home", "room"),
643
+ ("fridge", "Fridge", "kitchen", "fridge"),
644
+ ("fridge_door", "Fridge Door", "fridge", "fridge"),
645
+ ("fridge_top", "Fridge Top Shelf", "fridge", "fridge"),
646
+ ("fridge_drawer", "Fridge Vegetable Drawer", "fridge", "fridge"),
647
+ ("freezer", "Freezer", "fridge", "freezer"),
648
+ ("pantry", "Pantry", "kitchen", "pantry"),
649
+ ("pantry_top", "Pantry Top Shelf", "pantry", "shelf"),
650
+ ("pantry_mid", "Pantry Middle Shelf", "pantry", "shelf"),
651
+ ("spice_box", "Spice Box", "pantry", "shelf"),
652
+ ("bathroom", "Bathroom", None, "room"),
653
+ ("bathroom_cabinet", "Bathroom Cabinet", "bathroom", "cabinet"),
654
+ ("bathroom_sink", "Under Bathroom Sink", "bathroom", "cabinet"),
655
+ ("bedroom", "Bedroom", None, "room"),
656
+ ("medicine_drawer", "Medicine Drawer", "bedroom", "drawer"),
657
+ ("balcony", "Balcony", None, "balcony"),
658
+ ("cleaning_shelf", "Balcony Cleaning Shelf", "balcony", "shelf"),
659
+ ]
660
+ for loc_id, name, parent, loc_type in locations:
661
+ self.conn.execute(
662
+ "INSERT INTO household_locations (location_id, name, parent_location_id, location_type) VALUES (?, ?, ?, ?)",
663
+ (loc_id, name, parent, loc_type),
664
+ )
665
+
666
+ # --- Trace retention policy ---
667
+
668
+ def _apply_trace_retention_policy(self) -> None:
669
+ max_rows = max(0, settings.trace_max_rows)
670
+ ttl_days = settings.trace_ttl_days
671
+ if max_rows:
672
+ self.prune_traces(max_rows=max_rows)
673
+ if ttl_days:
674
+ self.prune_traces(ttl_days=ttl_days)
675
+
676
+ def prune_traces(self, max_rows: int | None = None, ttl_days: int | None = None) -> int:
677
+ removed = 0
678
+ if max_rows is not None and max_rows > 0:
679
+ cursor = self.conn.execute(
680
+ """
681
+ DELETE FROM traces
682
+ WHERE rowid NOT IN (
683
+ SELECT rowid FROM traces
684
+ ORDER BY datetime(timestamp) DESC, rowid DESC LIMIT ?
685
+ )
686
+ """,
687
+ (max_rows,),
688
+ )
689
+ removed += cursor.rowcount
690
+
691
+ if ttl_days is not None and ttl_days > 0:
692
+ cutoff = (datetime.now() - timedelta(days=ttl_days)).isoformat()
693
+ cursor = self.conn.execute(
694
+ "DELETE FROM traces WHERE datetime(timestamp) < datetime(?)",
695
+ (cutoff,),
696
+ )
697
+ removed += cursor.rowcount
698
+
699
+ self.conn.commit()
700
+ return removed
701
+
702
+ def get_trace_by_id(self, trace_id: str, user_id: str = "") -> Trace | None:
703
+ target = (trace_id or "").strip()
704
+ if not target:
705
+ return None
706
+ query = "SELECT * FROM traces WHERE trace_id = ?"
707
+ params: list[str] = [target]
708
+ if user_id:
709
+ query += " AND user_id = ?"
710
+ params.append(user_id)
711
+ row = self.conn.execute(query, params).fetchone()
712
+ return _row_to_trace(row) if row else None
713
+
714
+ # --- Inventory CRUD ---
715
+
716
+ def add_inventory_lot(self, lot: InventoryLot, user_id: str = "") -> InventoryLot:
717
+ # ── Phase 11: permission gate (supersession-safe additive check) ──
718
+ # The unwrapped behavior is preserved; we just fail closed
719
+ # on permission denial. The old method is not renamed or
720
+ # removed — it stays a single source of truth, with a
721
+ # permission check at the top.
722
+ from shopstack.services.permissions import require_write as _rw
723
+ if not user_id:
724
+ user_id = self.active_household_id
725
+ # user_id doubles as the household scope being written to; check
726
+ # self-membership (seeded by _seed_default_household for every
727
+ # household) rather than against self.active_household_id, which
728
+ # may be a different household than the one being written.
729
+ _rw(user_id, user_id, self) # raises PermissionError on deny
730
+ self.conn.execute(
731
+ """INSERT INTO inventory_lots
732
+ (lot_id, canonical_name, display_name, category, quantity, unit,
733
+ storage_location_id, purchase_date, estimated_use_by_date,
734
+ label_expiry_date, opened_date, price_paid, currency,
735
+ source_event_id, confidence, image_crop_path, status, created_at, updated_at, user_id)
736
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
737
+ (
738
+ lot.lot_id, lot.canonical_name, lot.display_name, lot.category,
739
+ lot.quantity, lot.unit, lot.storage_location_id,
740
+ _d(lot.purchase_date), _d(lot.estimated_use_by_date),
741
+ _d(lot.label_expiry_date), _d(lot.opened_date),
742
+ lot.price_paid, lot.currency, lot.source_event_id,
743
+ lot.confidence, lot.image_crop_path, lot.status,
744
+ lot.created_at.isoformat(), lot.updated_at.isoformat(),
745
+ user_id,
746
+ ),
747
+ )
748
+ self.conn.commit()
749
+ return lot
750
+
751
+ def update_inventory_lot(self, lot_id: str, updates: dict, user_id: str = "") -> InventoryLot | None:
752
+ existing = self.get_inventory_lot(lot_id)
753
+ if not existing:
754
+ return None
755
+ fields = ["canonical_name", "display_name", "category", "quantity", "unit",
756
+ "storage_location_id", "purchase_date", "estimated_use_by_date",
757
+ "label_expiry_date", "opened_date", "price_paid", "currency",
758
+ "confidence", "image_crop_path", "status", "user_id"]
759
+ set_clauses = []
760
+ vals = []
761
+ for f in fields:
762
+ if f in updates:
763
+ set_clauses.append(f"{f} = ?")
764
+ vals.append(updates[f])
765
+ if set_clauses:
766
+ set_clauses.append("updated_at = ?")
767
+ vals.append(datetime.now().isoformat())
768
+ vals.append(lot_id)
769
+ self.conn.execute(
770
+ f"UPDATE inventory_lots SET {', '.join(set_clauses)} WHERE lot_id = ?",
771
+ vals,
772
+ )
773
+ self.conn.commit()
774
+ return self.get_inventory_lot(lot_id)
775
+
776
+ def get_inventory_lot(self, lot_id: str) -> InventoryLot | None:
777
+ row = self.conn.execute(
778
+ "SELECT * FROM inventory_lots WHERE lot_id = ?", (lot_id,)
779
+ ).fetchone()
780
+ return _row_to_lot(row) if row else None
781
+
782
+ def get_inventory_lot_ids(self, lot_id_prefix: str) -> list[str]:
783
+ if not lot_id_prefix:
784
+ return []
785
+ exact = self.get_inventory_lot(lot_id_prefix)
786
+ if exact:
787
+ return [lot_id_prefix]
788
+ rows = self.conn.execute(
789
+ "SELECT lot_id FROM inventory_lots WHERE lot_id LIKE ? ORDER BY lot_id", (f"{lot_id_prefix}%",)
790
+ ).fetchall()
791
+ return [r["lot_id"] for r in rows]
792
+
793
+ def resolve_inventory_lot_id(self, lot_id_or_prefix: str) -> str | None:
794
+ ids = self.get_inventory_lot_ids(lot_id_or_prefix)
795
+ if len(ids) == 1:
796
+ return ids[0]
797
+ return None
798
+
799
+ def get_inventory(
800
+ self, status: str | None = None, location_id: str | None = None,
801
+ category: str | None = None, user_id: str = "",
802
+ canonical_name: str | None = None,
803
+ ) -> list[InventoryLot]:
804
+ parts = ["SELECT * FROM inventory_lots WHERE 1=1"]
805
+ params: list[Any] = []
806
+ if user_id:
807
+ parts.append("AND user_id = ?")
808
+ params.append(user_id)
809
+ if status:
810
+ parts.append("AND status = ?")
811
+ params.append(status)
812
+ if location_id:
813
+ parts.append("AND storage_location_id = ?")
814
+ params.append(location_id)
815
+ if category:
816
+ parts.append("AND category = ?")
817
+ params.append(category)
818
+ if canonical_name:
819
+ parts.append("AND canonical_name = ?")
820
+ params.append(canonical_name)
821
+ parts.append("ORDER BY created_at DESC")
822
+ rows = self.conn.execute(" ".join(parts), params).fetchall()
823
+ return [_row_to_lot(r) for r in rows if r]
824
+
825
+ def consume_inventory(self, lot_id: str, quantity: float) -> InventoryLot | None:
826
+ # ── Phase 11: permission gate (additive, supersession-safe) ──
827
+ from shopstack.services.permissions import require_write as _rw
828
+ if quantity < 0:
829
+ raise ValueError("quantity must be greater than 0")
830
+ lot = self.get_inventory_lot(lot_id)
831
+ if not lot:
832
+ return None
833
+ # The lot's user_id is the household. Check that the active
834
+ # user is a member of that household with write access.
835
+ _rw(lot.user_id or self.active_household_id,
836
+ self.active_household_id, self)
837
+ new_qty = max(0.0, lot.quantity - quantity)
838
+ status = lot.status
839
+ if new_qty <= 0:
840
+ new_qty = 0
841
+ status = "used"
842
+ elif new_qty < lot.quantity * 0.2:
843
+ status = "low"
844
+ return self.update_inventory_lot(lot_id, {"quantity": new_qty, "status": status})
845
+
846
+ def mark_list_complete(self, list_id: str) -> None:
847
+ self.conn.execute(
848
+ "UPDATE shopping_lists SET is_active = 0, updated_at = ? WHERE list_id = ?",
849
+ (datetime.now().isoformat(), list_id),
850
+ )
851
+ self.conn.commit()
852
+
853
+ # --- Shopping List CRUD ---
854
+
855
+ def create_shopping_list(
856
+ self,
857
+ name: str = "Shopping List",
858
+ goal: str = "",
859
+ user_id: str = "",
860
+ list_id: str | None = None,
861
+ ) -> ShoppingList:
862
+ """Create a new shopping list. By default the list_id is auto-generated;
863
+ callers (e.g. backup restore) can pass ``list_id`` to preserve an
864
+ existing id from the source DB.
865
+ """
866
+ if list_id is not None:
867
+ sl = ShoppingList(name=name, goal=goal)
868
+ sl.list_id = list_id
869
+ else:
870
+ sl = ShoppingList(name=name, goal=goal)
871
+ self.conn.execute(
872
+ "INSERT INTO shopping_lists (list_id, name, created_at, updated_at, goal, is_active, user_id) VALUES (?, ?, ?, ?, ?, 1, ?)",
873
+ (sl.list_id, sl.name, sl.created_at.isoformat(), sl.updated_at.isoformat(), sl.goal, user_id),
874
+ )
875
+ self.conn.commit()
876
+ return sl
877
+
878
+ def get_active_shopping_list(self, user_id: str = "") -> ShoppingList | None:
879
+ query = "SELECT * FROM shopping_lists WHERE is_active = 1"
880
+ params: list[str] = []
881
+ if user_id:
882
+ query += " AND user_id = ?"
883
+ params.append(user_id)
884
+ query += " ORDER BY created_at DESC LIMIT 1"
885
+ row = self.conn.execute(query, params).fetchone()
886
+ if not row:
887
+ return None
888
+ return _row_to_list(row, self.conn)
889
+
890
+ def add_list_item(self, list_id: str, item: ShoppingListItem) -> ShoppingListItem:
891
+ # ── Phase 11: permission gate ──
892
+ # Uses active_household_id as both user and household because
893
+ # add_list_item doesn't receive an explicit user_id parameter.
894
+ # The seed creates household_id as its own owner member, so
895
+ # this check succeeds for the default case. If per-user
896
+ # tracking is added later, this should accept a user_id param.
897
+ from shopstack.services.permissions import require_write as _rw
898
+ _rw(self.active_household_id, self.active_household_id, self)
899
+ self.conn.execute(
900
+ "INSERT INTO shopping_list_items (item_id, list_id, canonical_name, requested_quantity, unit, priority, reason, status, linked_lots) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
901
+ (item.list_item_id, list_id, item.canonical_name, item.requested_quantity,
902
+ item.unit, item.priority, item.reason, item.status,
903
+ json.dumps(item.linked_inventory_lots)),
904
+ )
905
+ self.conn.execute(
906
+ "UPDATE shopping_lists SET updated_at = ? WHERE list_id = ?",
907
+ (datetime.now().isoformat(), list_id),
908
+ )
909
+ self.conn.commit()
910
+ return item
911
+
912
+ def update_list_item(self, item_id: str, updates: dict) -> None:
913
+ item = self.conn.execute(
914
+ "SELECT * FROM shopping_list_items WHERE item_id = ?", (item_id,)
915
+ ).fetchone()
916
+ if not item:
917
+ return
918
+ fields = ["canonical_name", "requested_quantity", "unit", "priority", "reason", "status"]
919
+ set_clauses = []
920
+ vals = []
921
+ for f in fields:
922
+ if f in updates:
923
+ set_clauses.append(f"{f} = ?")
924
+ vals.append(updates[f])
925
+ if set_clauses:
926
+ vals.append(item_id)
927
+ self.conn.execute(
928
+ f"UPDATE shopping_list_items SET {', '.join(set_clauses)} WHERE item_id = ?",
929
+ vals,
930
+ )
931
+ self.conn.commit()
932
+ if "list_id" in updates:
933
+ self.conn.execute(
934
+ "UPDATE shopping_lists SET updated_at = ? WHERE list_id = ?",
935
+ (datetime.now().isoformat(), updates["list_id"]),
936
+ )
937
+ self.conn.commit()
938
+
939
+ # --- Locations ---
940
+
941
+ def get_locations(self) -> list[HouseholdLocation]:
942
+ rows = self.conn.execute("SELECT * FROM household_locations ORDER BY name").fetchall()
943
+ return [_row_to_location(r) for r in rows]
944
+
945
+ def get_location(self, location_id: str) -> HouseholdLocation | None:
946
+ row = self.conn.execute(
947
+ "SELECT * FROM household_locations WHERE location_id = ?", (location_id,)
948
+ ).fetchone()
949
+ return _row_to_location(row) if row else None
950
+
951
+ # --- Movements ---
952
+
953
+ def record_movement(self, movement: MovementEvent) -> MovementEvent:
954
+ # ── Phase 11: permission gate ──
955
+ # Uses active_household_id as both user and household because
956
+ # record_movement doesn't receive an explicit user_id parameter.
957
+ # See add_list_item for rationale.
958
+ from shopstack.services.permissions import require_write as _rw
959
+ _rw(self.active_household_id, self.active_household_id, self)
960
+ self.conn.execute(
961
+ "INSERT INTO movement_events (movement_id, lot_id, from_location_id, to_location_id, timestamp, source, confidence) VALUES (?, ?, ?, ?, ?, ?, ?)",
962
+ (movement.movement_id, movement.lot_id, movement.from_location_id,
963
+ movement.to_location_id, movement.timestamp.isoformat(),
964
+ movement.source, movement.confidence),
965
+ )
966
+ self.conn.execute(
967
+ "UPDATE inventory_lots SET storage_location_id = ?, updated_at = ? WHERE lot_id = ?",
968
+ (movement.to_location_id, datetime.now().isoformat(), movement.lot_id),
969
+ )
970
+ self.conn.commit()
971
+ return movement
972
+
973
+ def get_movements_for_lot(self, lot_id: str) -> list[MovementEvent]:
974
+ rows = self.conn.execute(
975
+ "SELECT * FROM movement_events WHERE lot_id = ? ORDER BY timestamp DESC",
976
+ (lot_id,),
977
+ ).fetchall()
978
+ return [_row_to_movement(r) for r in rows]
979
+
980
+ # --- Negative Memory (Object Trail) ---
981
+
982
+ def add_negative_memory(self, lot_id: str, location_id: str, location_name: str = "", source: str = "user_feedback", confidence: float = 1.0, user_id: str = "") -> dict:
983
+ """Record that an item has been confirmed NOT to be at a location."""
984
+ memory_id = f"negmem_{new_id()}"
985
+ self.conn.execute(
986
+ "INSERT INTO negative_memory (memory_id, lot_id, location_id, location_name, confirmed_at, source, confidence, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
987
+ (memory_id, lot_id, location_id, location_name, datetime.now().isoformat(), source, confidence, user_id),
988
+ )
989
+ self.conn.commit()
990
+ return {"memory_id": memory_id, "lot_id": lot_id, "location_id": location_id}
991
+
992
+ def get_negative_memory_for_lot(self, lot_id: str) -> list[dict]:
993
+ """Get all negative memory entries for a given lot."""
994
+ rows = self.conn.execute(
995
+ "SELECT * FROM negative_memory WHERE lot_id = ? ORDER BY confirmed_at DESC",
996
+ (lot_id,),
997
+ ).fetchall()
998
+ return [dict(r) for r in rows]
999
+
1000
+ def delete_negative_memory(self, memory_id: str) -> bool:
1001
+ """Remove a negative memory entry."""
1002
+ self.conn.execute("DELETE FROM negative_memory WHERE memory_id = ?", (memory_id,))
1003
+ self.conn.commit()
1004
+ return True
1005
+
1006
+ # --- Person Associations (Object Trail) ---
1007
+
1008
+ def add_person_association(self, lot_id: str, person_id: str, person_name: str, relationship: str = "owner", confidence: float = 1.0, user_id: str = "") -> dict:
1009
+ """Record a person association for an item."""
1010
+ association_id = f"personassoc_{new_id()}"
1011
+ self.conn.execute(
1012
+ "INSERT INTO person_associations (association_id, lot_id, person_id, person_name, relationship, confidence, user_id) VALUES (?, ?, ?, ?, ?, ?, ?)",
1013
+ (association_id, lot_id, person_id, person_name, relationship, confidence, user_id),
1014
+ )
1015
+ self.conn.commit()
1016
+ return {"association_id": association_id, "lot_id": lot_id, "person_id": person_id}
1017
+
1018
+ def get_person_associations_for_lot(self, lot_id: str) -> list[dict]:
1019
+ """Get all person associations for a given lot."""
1020
+ rows = self.conn.execute(
1021
+ "SELECT * FROM person_associations WHERE lot_id = ?",
1022
+ (lot_id,),
1023
+ ).fetchall()
1024
+ return [dict(r) for r in rows]
1025
+
1026
+ def delete_person_association(self, association_id: str) -> bool:
1027
+ """Remove a person association."""
1028
+ self.conn.execute("DELETE FROM person_associations WHERE association_id = ?", (association_id,))
1029
+ self.conn.commit()
1030
+ return True
1031
+
1032
+ # --- Price Observations ---
1033
+
1034
+ def record_price(self, price: PriceObservation, user_id: str = "") -> PriceObservation:
1035
+ # ── Phase 11: permission gate (additive, supersession-safe) ──
1036
+ from shopstack.services.permissions import require_write as _rw
1037
+ if not user_id:
1038
+ user_id = self.active_household_id
1039
+ _rw(user_id, user_id, self)
1040
+ self.conn.execute(
1041
+ "INSERT INTO price_observations (price_id, canonical_name, quantity, unit, price, currency, store_name, store_id, observation_date, source_event_id, notes, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1042
+ (price.price_id, price.canonical_name, price.quantity, price.unit,
1043
+ price.price, price.currency, price.store_name, price.store_id,
1044
+ _d(price.observation_date), price.source_event_id, price.notes,
1045
+ user_id),
1046
+ )
1047
+ self.conn.commit()
1048
+ return price
1049
+
1050
+ def get_price_history(self, canonical_name: str, user_id: str = "") -> list[PriceObservation]:
1051
+ query = "SELECT * FROM price_observations WHERE canonical_name = ?"
1052
+ params: list[str | None] = [canonical_name]
1053
+ if user_id:
1054
+ query += " AND user_id = ?"
1055
+ params.append(user_id)
1056
+ query += " ORDER BY observation_date DESC, rowid DESC"
1057
+ rows = self.conn.execute(query, params).fetchall()
1058
+ return [_row_to_price(r) for r in rows]
1059
+
1060
+ # --- Stores ---
1061
+
1062
+ def add_store(self, store: Store) -> Store:
1063
+ self.conn.execute(
1064
+ "INSERT INTO stores (store_id, name, location, store_type, notes) VALUES (?, ?, ?, ?, ?)",
1065
+ (store.store_id, store.name, store.location, store.store_type, store.notes),
1066
+ )
1067
+ self.conn.commit()
1068
+ return store
1069
+
1070
+ def get_stores(self) -> list[Store]:
1071
+ rows = self.conn.execute("SELECT * FROM stores ORDER BY name").fetchall()
1072
+ return [_row_to_store(r) for r in rows]
1073
+
1074
+ # --- App Config ---
1075
+
1076
+ def get_config_value(self, key: str, default: str = "") -> str:
1077
+ row = self.conn.execute(
1078
+ "SELECT value FROM app_config WHERE key = ?",
1079
+ (key,),
1080
+ ).fetchone()
1081
+ return row["value"] if row else default
1082
+
1083
+ def set_config_value(self, key: str, value: str) -> None:
1084
+ self.conn.execute(
1085
+ "INSERT OR REPLACE INTO app_config (key, value) VALUES (?, ?)",
1086
+ (key, value),
1087
+ )
1088
+ self.conn.commit()
1089
+
1090
+ # --- Traces ---
1091
+
1092
+ def save_trace(self, trace: Trace, user_id: str = "") -> Trace:
1093
+ if not user_id:
1094
+ user_id = self.active_household_id
1095
+ self.conn.execute(
1096
+ "INSERT OR REPLACE INTO traces (trace_id, input_type, user_goal, redacted_user_request, perception, inventory_context, decision, proposed_tool_calls, human_confirmation, final_response, timestamp, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1097
+ (
1098
+ trace.trace_id, trace.input_type, trace.user_goal,
1099
+ trace.redacted_user_request, json.dumps(trace.perception),
1100
+ json.dumps(trace.inventory_context), json.dumps(trace.decision),
1101
+ json.dumps([t.model_dump() for t in trace.proposed_tool_calls], default=str),
1102
+ trace.human_confirmation, trace.final_response,
1103
+ trace.timestamp.isoformat(),
1104
+ user_id,
1105
+ ),
1106
+ )
1107
+ self.conn.commit()
1108
+ self.prune_traces(
1109
+ max_rows=max(0, settings.trace_max_rows),
1110
+ ttl_days=settings.trace_ttl_days,
1111
+ )
1112
+ return trace
1113
+
1114
+ def get_traces(self, limit: int = 50, user_id: str = "") -> list[Trace]:
1115
+ query = "SELECT * FROM traces"
1116
+ params: list[str | int] = []
1117
+ if user_id:
1118
+ query += " WHERE user_id = ?"
1119
+ params.append(user_id)
1120
+ query += " ORDER BY timestamp DESC LIMIT ?"
1121
+ params.append(limit)
1122
+ rows = self.conn.execute(query, params).fetchall()
1123
+ return [_row_to_trace(r) for r in rows]
1124
+
1125
+ # --- Purchase Events ---
1126
+
1127
+ def add_purchase_event(self, event: PurchaseEvent, user_id: str = "") -> PurchaseEvent:
1128
+ # ── Phase 11: permission gate (additive, supersession-safe) ──
1129
+ from shopstack.services.permissions import require_write as _rw
1130
+ if not user_id:
1131
+ user_id = self.active_household_id
1132
+ _rw(user_id, user_id, self)
1133
+ self.conn.execute(
1134
+ "INSERT INTO purchase_events (event_id, timestamp, canonical_name, quantity, unit, total_price, currency, source_type, store_name, raw_text, source_file_path, confirmed, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1135
+ (event.event_id, event.timestamp.isoformat(),
1136
+ event.canonical_name, event.quantity, event.unit,
1137
+ event.total_price, event.currency, event.source_type,
1138
+ event.store_name, event.raw_text, event.source_file_path,
1139
+ 1 if event.confirmed else 0,
1140
+ user_id),
1141
+ )
1142
+ self.conn.commit()
1143
+ return event
1144
+
1145
+ def get_purchase_events(self, limit: int = 20, user_id: str = "") -> list[PurchaseEvent]:
1146
+ query = "SELECT * FROM purchase_events"
1147
+ params: list[str | int] = []
1148
+ if user_id:
1149
+ query += " WHERE user_id = ?"
1150
+ params.append(user_id)
1151
+ query += " ORDER BY timestamp DESC LIMIT ?"
1152
+ params.append(limit)
1153
+ rows = self.conn.execute(query, params).fetchall()
1154
+ return [_row_to_purchase(r) for r in rows]
1155
+
1156
+ def get_purchases(self, limit: int = 20) -> list[PurchaseEvent]:
1157
+ return self.get_purchase_events(limit=limit)
1158
+
1159
+ # --- Reconciliation Events ---
1160
+
1161
+ def add_reconciliation_event(self, event: ReconciliationEvent, user_id: str = "") -> ReconciliationEvent:
1162
+ # ── Phase 11: permission gate (additive, supersession-safe) ──
1163
+ from shopstack.services.permissions import require_write as _rw
1164
+ if not user_id:
1165
+ user_id = self.active_household_id
1166
+ _rw(user_id, user_id, self)
1167
+ self.conn.execute(
1168
+ "INSERT INTO reconciliation_events (event_id, timestamp, canonical_name, planned_action, actual_action, quantity, unit, price_paid, planned_price, substituted_with, notes, source, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1169
+ (event.event_id, event.timestamp.isoformat(),
1170
+ event.canonical_name, event.planned_action, event.actual_action,
1171
+ event.quantity, event.unit, event.price_paid, event.planned_price,
1172
+ event.substituted_with, event.notes, event.source, user_id),
1173
+ )
1174
+ self.conn.commit()
1175
+ return event
1176
+
1177
+ def get_reconciliation_events(self, canonical_name: str | None = None, limit: int = 20, user_id: str = "") -> list[ReconciliationEvent]:
1178
+ query = "SELECT * FROM reconciliation_events WHERE 1=1"
1179
+ params: list[str | int] = []
1180
+ if canonical_name:
1181
+ query += " AND canonical_name = ?"
1182
+ params.append(canonical_name)
1183
+ if user_id:
1184
+ query += " AND user_id = ?"
1185
+ params.append(user_id)
1186
+ query += " ORDER BY timestamp DESC LIMIT ?"
1187
+ params.append(limit)
1188
+ rows = self.conn.execute(query, params).fetchall()
1189
+ return [_row_to_reconciliation(r) for r in rows]
1190
+
1191
+ # --- Inventory Events (audit trail) ---
1192
+
1193
+ def record_inventory_event(self, event: InventoryEvent, user_id: str = "") -> InventoryEvent:
1194
+ self.conn.execute(
1195
+ """INSERT INTO inventory_events
1196
+ (event_id, timestamp, lot_id, canonical_name, action,
1197
+ quantity_before, quantity_after, quantity_delta, unit,
1198
+ location_from, location_to, source, notes, user_id)
1199
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
1200
+ (event.event_id, event.timestamp.isoformat(),
1201
+ event.lot_id, event.canonical_name, event.action,
1202
+ event.quantity_before, event.quantity_after, event.quantity_delta,
1203
+ event.unit, event.location_from, event.location_to,
1204
+ event.source, event.notes, user_id),
1205
+ )
1206
+ self.conn.commit()
1207
+ return event
1208
+
1209
+ def get_inventory_events(self, canonical_name: str = "", lot_id: str = "", limit: int = 50) -> list[InventoryEvent]:
1210
+ query = "SELECT * FROM inventory_events"
1211
+ params: list[Any] = []
1212
+ conditions = []
1213
+ if canonical_name:
1214
+ conditions.append("canonical_name = ?")
1215
+ params.append(canonical_name.lower())
1216
+ if lot_id:
1217
+ conditions.append("lot_id = ?")
1218
+ params.append(lot_id)
1219
+ if conditions:
1220
+ query += " WHERE " + " AND ".join(conditions)
1221
+ query += " ORDER BY timestamp DESC LIMIT ?"
1222
+ params.append(limit)
1223
+ rows = self.conn.execute(query, params).fetchall()
1224
+ return [_row_to_inventory_event(r) for r in rows]
1225
+
1226
+ def get_inventory_timeline(self, canonical_name: str, limit: int = 20) -> list[InventoryEvent]:
1227
+ return self.get_inventory_events(canonical_name=canonical_name, limit=limit)
1228
+
1229
+ # --- Preference Signals ---
1230
+
1231
+ def add_preference_signal(self, signal: PreferenceSignal, user_id: str = "") -> PreferenceSignal:
1232
+ # ── Phase 11: permission gate (additive, supersession-safe) ──
1233
+ from shopstack.services.permissions import require_write as _rw
1234
+ if not user_id:
1235
+ user_id = self.active_household_id
1236
+ _rw(user_id, user_id, self)
1237
+ self.conn.execute(
1238
+ "INSERT INTO preference_signals (signal_id, canonical_name, signal_type, value, confidence, source, created_at, updated_at, user_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1239
+ (signal.signal_id, signal.canonical_name, signal.signal_type,
1240
+ signal.value, signal.confidence, signal.source,
1241
+ signal.created_at.isoformat(), signal.updated_at.isoformat(), user_id),
1242
+ )
1243
+ self.conn.commit()
1244
+ return signal
1245
+
1246
+ def get_preference_signals(self, canonical_name: str | None = None, user_id: str = "") -> list[PreferenceSignal]:
1247
+ query = "SELECT * FROM preference_signals WHERE 1=1"
1248
+ params: list[str] = []
1249
+ if canonical_name:
1250
+ query += " AND canonical_name = ?"
1251
+ params.append(canonical_name)
1252
+ if user_id:
1253
+ query += " AND user_id = ?"
1254
+ params.append(user_id)
1255
+ query += " ORDER BY updated_at DESC"
1256
+ rows = self.conn.execute(query, params).fetchall()
1257
+ return [_row_to_preference(r) for r in rows]
1258
+
1259
+ def delete_preference_signal(self, signal_id: str) -> bool:
1260
+ cursor = self.conn.execute(
1261
+ "DELETE FROM preference_signals WHERE signal_id = ?",
1262
+ (signal_id,),
1263
+ )
1264
+ self.conn.commit()
1265
+ return cursor.rowcount > 0
1266
+
1267
+ # --- Market Snapshot Records ---
1268
+
1269
+ def save_market_snapshot(self, snapshot) -> bool:
1270
+ from shopstack.market.schema import NormalizedMarketRecord, MarketSnapshot
1271
+ if not isinstance(snapshot, MarketSnapshot):
1272
+ return False
1273
+
1274
+ self.conn.execute(
1275
+ """
1276
+ INSERT OR REPLACE INTO market_snapshots
1277
+ (snapshot_id, source, source_category, captured_at, record_count, analytics, freshness_context, stored_at)
1278
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
1279
+ """,
1280
+ (
1281
+ snapshot.snapshot_id,
1282
+ snapshot.source,
1283
+ snapshot.source_category,
1284
+ snapshot.captured_at,
1285
+ len(snapshot.normalized_records),
1286
+ json.dumps(snapshot.analytics or {}, sort_keys=True),
1287
+ snapshot.analytics.get("freshness", "unknown") if isinstance(snapshot.analytics, dict) else "unknown",
1288
+ datetime.now(timezone.utc).isoformat(),
1289
+ ),
1290
+ )
1291
+ self.conn.execute("DELETE FROM market_record_components WHERE record_id IN (SELECT record_id FROM market_records WHERE snapshot_id = ?)", (snapshot.snapshot_id,))
1292
+ self.conn.execute("DELETE FROM market_records WHERE snapshot_id = ?", (snapshot.snapshot_id,))
1293
+
1294
+ for idx, record in enumerate(snapshot.normalized_records):
1295
+ if not isinstance(record, NormalizedMarketRecord):
1296
+ continue
1297
+ record_id = f"{snapshot.snapshot_id}::{idx:05d}"
1298
+ self.conn.execute(
1299
+ """
1300
+ INSERT OR REPLACE INTO market_records (
1301
+ record_id, snapshot_id, raw_name, canonical_name, description, raw_size, normalized_quantity,
1302
+ normalized_unit, package_count, is_combo, is_weight_based, is_piece_based, is_size_class,
1303
+ size_class, price_inr, mrp_inr, discount_percent_displayed, discount_amount_inr,
1304
+ computed_discount_percent, availability, is_available, tag, is_ad, is_upgrade, card_index,
1305
+ delivery_time, price_per_kg, price_per_100g, price_per_piece, normalization_warnings, variety, brand
1306
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1307
+ """,
1308
+ (
1309
+ record_id,
1310
+ snapshot.snapshot_id,
1311
+ record.raw_name,
1312
+ record.canonical_name,
1313
+ record.description,
1314
+ record.raw_size,
1315
+ record.normalized_quantity,
1316
+ record.normalized_unit,
1317
+ record.package_count,
1318
+ int(record.is_combo),
1319
+ int(record.is_weight_based),
1320
+ int(record.is_piece_based),
1321
+ int(record.is_size_class),
1322
+ record.size_class,
1323
+ record.price_inr,
1324
+ record.mrp_inr,
1325
+ record.discount_percent_displayed,
1326
+ record.discount_amount_inr,
1327
+ record.computed_discount_percent,
1328
+ record.availability,
1329
+ int(record.is_available),
1330
+ record.tag,
1331
+ int(record.is_ad),
1332
+ int(record.is_upgrade),
1333
+ record.card_index,
1334
+ record.delivery_time,
1335
+ record.price_per_kg,
1336
+ record.price_per_100g,
1337
+ record.price_per_piece,
1338
+ json.dumps(record.normalization_warnings or []),
1339
+ record.variety,
1340
+ record.brand,
1341
+ ),
1342
+ )
1343
+ self.conn.execute("DELETE FROM market_record_components WHERE record_id = ?", (record_id,))
1344
+ for component_name in record.component_names:
1345
+ component_id = f"{record_id}::{component_name}"
1346
+ self.conn.execute(
1347
+ "INSERT OR IGNORE INTO market_record_components (component_id, record_id, component_name) VALUES (?, ?, ?)",
1348
+ (component_id, record_id, component_name),
1349
+ )
1350
+
1351
+ self.conn.commit()
1352
+ return True
1353
+
1354
+ def get_market_snapshot(self, snapshot_id: str):
1355
+ from shopstack.market.schema import MarketSnapshot
1356
+ row = self.conn.execute(
1357
+ "SELECT snapshot_id, source, source_category, captured_at, analytics FROM market_snapshots WHERE snapshot_id = ?",
1358
+ (snapshot_id,),
1359
+ ).fetchone()
1360
+ if not row:
1361
+ return None
1362
+
1363
+ return MarketSnapshot(
1364
+ snapshot_id=row["snapshot_id"],
1365
+ source=row["source"],
1366
+ source_category=row["source_category"],
1367
+ captured_at=row["captured_at"],
1368
+ raw_records=[],
1369
+ normalized_records=self.get_market_records(snapshot_id),
1370
+ analytics=json.loads(row["analytics"] or "{}"),
1371
+ )
1372
+
1373
+ def get_latest_market_snapshot(self, source: str):
1374
+ row = self.conn.execute(
1375
+ "SELECT snapshot_id FROM market_snapshots WHERE source = ? ORDER BY captured_at DESC LIMIT 1",
1376
+ (source,),
1377
+ ).fetchone()
1378
+ if not row:
1379
+ return None
1380
+ return self.get_market_snapshot(row["snapshot_id"])
1381
+
1382
+ def get_market_records(self, snapshot_id: str) -> list:
1383
+ from shopstack.market.schema import NormalizedMarketRecord
1384
+ rows = self.conn.execute(
1385
+ "SELECT * FROM market_records WHERE snapshot_id = ? ORDER BY card_index ASC, raw_name ASC",
1386
+ (snapshot_id,),
1387
+ ).fetchall()
1388
+
1389
+ snap_row = self.conn.execute(
1390
+ "SELECT source, source_category, captured_at FROM market_snapshots WHERE snapshot_id = ?",
1391
+ (snapshot_id,),
1392
+ ).fetchone()
1393
+
1394
+ source = snap_row["source"] if snap_row else ""
1395
+ source_category = snap_row["source_category"] if snap_row else ""
1396
+ captured_at = snap_row["captured_at"] if snap_row else ""
1397
+
1398
+ records: list[NormalizedMarketRecord] = []
1399
+ for row in rows:
1400
+ comp_rows = self.conn.execute(
1401
+ "SELECT component_name FROM market_record_components WHERE record_id = ? ORDER BY component_name ASC",
1402
+ (row["record_id"],),
1403
+ ).fetchall()
1404
+ components = [r["component_name"] for r in comp_rows]
1405
+ raw_warnings = row["normalization_warnings"]
1406
+ if isinstance(raw_warnings, str):
1407
+ try:
1408
+ warnings = json.loads(raw_warnings)
1409
+ except json.JSONDecodeError:
1410
+ warnings = []
1411
+ else:
1412
+ warnings = []
1413
+ records.append(
1414
+ NormalizedMarketRecord(
1415
+ source=source,
1416
+ source_category=source_category,
1417
+ raw_name=row["raw_name"],
1418
+ canonical_name=row["canonical_name"],
1419
+ description=row["description"],
1420
+ raw_size=row["raw_size"],
1421
+ normalized_quantity=row["normalized_quantity"],
1422
+ normalized_unit=row["normalized_unit"],
1423
+ package_count=row["package_count"],
1424
+ is_combo=bool(row["is_combo"]),
1425
+ is_weight_based=bool(row["is_weight_based"]),
1426
+ is_piece_based=bool(row["is_piece_based"]),
1427
+ is_size_class=bool(row["is_size_class"]),
1428
+ size_class=row["size_class"],
1429
+ price_inr=row["price_inr"],
1430
+ mrp_inr=row["mrp_inr"],
1431
+ discount_percent_displayed=row["discount_percent_displayed"],
1432
+ discount_amount_inr=row["discount_amount_inr"],
1433
+ computed_discount_percent=row["computed_discount_percent"],
1434
+ availability=row["availability"],
1435
+ is_available=bool(row["is_available"]),
1436
+ tag=row["tag"],
1437
+ is_ad=bool(row["is_ad"]),
1438
+ is_upgrade=bool(row["is_upgrade"]),
1439
+ card_index=row["card_index"],
1440
+ delivery_time=row["delivery_time"],
1441
+ captured_at=captured_at,
1442
+ snapshot_id=row["snapshot_id"],
1443
+ price_per_kg=row["price_per_kg"],
1444
+ price_per_100g=row["price_per_100g"],
1445
+ price_per_piece=row["price_per_piece"],
1446
+ normalization_warnings=warnings,
1447
+ component_names=components,
1448
+ variety=row["variety"],
1449
+ brand=row["brand"],
1450
+ )
1451
+ )
1452
+ return records
1453
+
1454
+ def get_records_by_canonical(self, canonical_name: str) -> list:
1455
+ from shopstack.market.schema import NormalizedMarketRecord
1456
+ # If normalized rows are requested without full context, use snapshot join in SQL.
1457
+ rows_with_snap = self.conn.execute(
1458
+ """
1459
+ SELECT mr.*, ms.source, ms.source_category, ms.captured_at
1460
+ FROM market_records AS mr
1461
+ JOIN market_snapshots AS ms ON ms.snapshot_id = mr.snapshot_id
1462
+ WHERE mr.canonical_name = ?
1463
+ ORDER BY ms.captured_at DESC
1464
+ """,
1465
+ (canonical_name,),
1466
+ ).fetchall()
1467
+ records: list[NormalizedMarketRecord] = []
1468
+ for row in rows_with_snap:
1469
+ raw_warnings = row["normalization_warnings"]
1470
+ if isinstance(raw_warnings, str):
1471
+ try:
1472
+ warnings = json.loads(raw_warnings)
1473
+ except json.JSONDecodeError:
1474
+ warnings = []
1475
+ else:
1476
+ warnings = []
1477
+
1478
+ _comp_rows = self.conn.execute(
1479
+ "SELECT component_name FROM market_record_components WHERE record_id = ? ORDER BY component_name ASC",
1480
+ (row["record_id"],),
1481
+ ).fetchall()
1482
+ records.append(
1483
+ NormalizedMarketRecord(
1484
+ source=row["source"],
1485
+ source_category=row["source_category"],
1486
+ raw_name=row["raw_name"],
1487
+ canonical_name=row["canonical_name"],
1488
+ description=row["description"],
1489
+ raw_size=row["raw_size"],
1490
+ normalized_quantity=row["normalized_quantity"],
1491
+ normalized_unit=row["normalized_unit"],
1492
+ package_count=row["package_count"],
1493
+ is_combo=bool(row["is_combo"]),
1494
+ is_weight_based=bool(row["is_weight_based"]),
1495
+ is_piece_based=bool(row["is_piece_based"]),
1496
+ is_size_class=bool(row["is_size_class"]),
1497
+ size_class=row["size_class"],
1498
+ price_inr=row["price_inr"],
1499
+ mrp_inr=row["mrp_inr"],
1500
+ discount_percent_displayed=row["discount_percent_displayed"],
1501
+ discount_amount_inr=row["discount_amount_inr"],
1502
+ computed_discount_percent=row["computed_discount_percent"],
1503
+ availability=row["availability"],
1504
+ is_available=bool(row["is_available"]),
1505
+ tag=row["tag"],
1506
+ is_ad=bool(row["is_ad"]),
1507
+ is_upgrade=bool(row["is_upgrade"]),
1508
+ card_index=row["card_index"],
1509
+ delivery_time=row["delivery_time"],
1510
+ captured_at=row["captured_at"],
1511
+ snapshot_id=row["snapshot_id"],
1512
+ price_per_kg=row["price_per_kg"],
1513
+ price_per_100g=row["price_per_100g"],
1514
+ price_per_piece=row["price_per_piece"],
1515
+ normalization_warnings=warnings,
1516
+ component_names=[r2["component_name"] for r2 in self.conn.execute(
1517
+ "SELECT component_name FROM market_record_components WHERE record_id = ? ORDER BY component_name ASC",
1518
+ (row["record_id"],),
1519
+ ).fetchall()],
1520
+ variety=row["variety"],
1521
+ brand=row["brand"],
1522
+ )
1523
+ )
1524
+ return records
1525
+
1526
+ def close(self) -> None:
1527
+ c = getattr(self._local, "conn", None)
1528
+ if c is not None:
1529
+ c.close()
1530
+ self._local.conn = None
1531
+
1532
+ def __enter__(self):
1533
+ return self
1534
+
1535
+ def __exit__(self, exc_type, exc, tb) -> None:
1536
+ self.close()
1537
+
1538
+ def __del__(self):
1539
+ # Defensive cleanup: ensure sqlite connections are closed even if callers
1540
+ # forget an explicit ``close()`` (important during module reloads and
1541
+ # test teardown paths that recreate app_context).
1542
+ try:
1543
+ self.close()
1544
+ except Exception:
1545
+ # Never raise during finalization; mirror sqlite's permissive close
1546
+ # semantics when objects are already partially torn down.
1547
+ pass
1548
+
1549
+
1550
+ def _d(dt: date | str | None) -> str | None:
1551
+ if dt is None:
1552
+ return None
1553
+ if isinstance(dt, str):
1554
+ return dt
1555
+ return dt.isoformat()
1556
+
1557
+
1558
+ def _row_to_lot(row: sqlite3.Row) -> InventoryLot:
1559
+ return InventoryLot(
1560
+ lot_id=row["lot_id"], canonical_name=row["canonical_name"],
1561
+ display_name=row["display_name"], category=row["category"],
1562
+ quantity=row["quantity"], unit=row["unit"],
1563
+ storage_location_id=row["storage_location_id"],
1564
+ purchase_date=_parse_d(row["purchase_date"]),
1565
+ estimated_use_by_date=_parse_d(row["estimated_use_by_date"]),
1566
+ label_expiry_date=_parse_d(row["label_expiry_date"]),
1567
+ opened_date=_parse_d(row["opened_date"]),
1568
+ price_paid=row["price_paid"], currency=row["currency"],
1569
+ source_event_id=row["source_event_id"], confidence=row["confidence"],
1570
+ image_crop_path=row["image_crop_path"], status=row["status"],
1571
+ user_id=row["user_id"] if "user_id" in row.keys() else "",
1572
+ created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(),
1573
+ updated_at=datetime.fromisoformat(row["updated_at"]) if row["updated_at"] else datetime.now(),
1574
+ )
1575
+
1576
+
1577
+ def _parse_d(val: str | None) -> date | None:
1578
+ if val is None:
1579
+ return None
1580
+ try:
1581
+ return date.fromisoformat(val)
1582
+ except (ValueError, TypeError):
1583
+ return None
1584
+
1585
+
1586
+ def _row_to_list(row: sqlite3.Row, conn: sqlite3.Connection) -> ShoppingList:
1587
+ item_rows = conn.execute(
1588
+ "SELECT * FROM shopping_list_items WHERE list_id = ?", (row["list_id"],)
1589
+ ).fetchall()
1590
+ items = []
1591
+ for ir in item_rows:
1592
+ items.append(ShoppingListItem(
1593
+ list_item_id=ir["item_id"], canonical_name=ir["canonical_name"],
1594
+ requested_quantity=ir["requested_quantity"], unit=ir["unit"],
1595
+ priority=ir["priority"], reason=ir["reason"], status=ir["status"],
1596
+ linked_inventory_lots=json.loads(ir["linked_lots"] or "[]"),
1597
+ ))
1598
+ return ShoppingList(
1599
+ list_id=row["list_id"], name=row["name"], goal=row["goal"],
1600
+ is_active=bool(row["is_active"]), items=items,
1601
+ created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(),
1602
+ updated_at=datetime.fromisoformat(row["updated_at"]) if row["updated_at"] else datetime.now(),
1603
+ )
1604
+
1605
+
1606
+ def _row_to_location(row: sqlite3.Row) -> HouseholdLocation:
1607
+ return HouseholdLocation(
1608
+ location_id=row["location_id"], name=row["name"],
1609
+ parent_location_id=row["parent_location_id"],
1610
+ location_type=row["location_type"], photo_path=row["photo_path"],
1611
+ notes=row["notes"],
1612
+ )
1613
+
1614
+
1615
+ def _row_to_movement(row: sqlite3.Row) -> MovementEvent:
1616
+ return MovementEvent(
1617
+ movement_id=row["movement_id"], lot_id=row["lot_id"],
1618
+ from_location_id=row["from_location_id"],
1619
+ to_location_id=row["to_location_id"],
1620
+ timestamp=datetime.fromisoformat(row["timestamp"]) if row["timestamp"] else datetime.now(),
1621
+ source=row["source"], confidence=row["confidence"],
1622
+ )
1623
+
1624
+
1625
+ def _row_to_price(row: sqlite3.Row) -> PriceObservation:
1626
+ parsed = _parse_d(row["observation_date"])
1627
+ return PriceObservation(
1628
+ price_id=row["price_id"], canonical_name=row["canonical_name"],
1629
+ quantity=row["quantity"], unit=row["unit"], price=row["price"],
1630
+ currency=row["currency"], store_name=row["store_name"],
1631
+ store_id=row["store_id"],
1632
+ observation_date=parsed if parsed is not None else date.today(),
1633
+ source_event_id=row["source_event_id"], notes=row["notes"],
1634
+ )
1635
+
1636
+
1637
+ def _row_to_store(row: sqlite3.Row) -> Store:
1638
+ return Store(
1639
+ store_id=row["store_id"], name=row["name"],
1640
+ location=row["location"], store_type=row["store_type"],
1641
+ notes=row["notes"],
1642
+ )
1643
+
1644
+
1645
+ def _row_to_trace(row: sqlite3.Row) -> Trace:
1646
+ return Trace(
1647
+ trace_id=row["trace_id"], input_type=row["input_type"],
1648
+ user_goal=row["user_goal"],
1649
+ redacted_user_request=row["redacted_user_request"],
1650
+ perception=json.loads(row["perception"] or "{}"),
1651
+ inventory_context=json.loads(row["inventory_context"] or "{}"),
1652
+ decision=json.loads(row["decision"] or "{}"),
1653
+ proposed_tool_calls=[
1654
+ _dict_to_tc(t) for t in json.loads(row["proposed_tool_calls"] or "[]")
1655
+ ],
1656
+ human_confirmation=row["human_confirmation"],
1657
+ final_response=row["final_response"],
1658
+ timestamp=datetime.fromisoformat(row["timestamp"]) if row["timestamp"] else datetime.now(),
1659
+ )
1660
+
1661
+
1662
+ def _row_to_purchase(row: sqlite3.Row) -> PurchaseEvent:
1663
+ return PurchaseEvent(
1664
+ event_id=row["event_id"],
1665
+ timestamp=datetime.fromisoformat(row["timestamp"]) if row["timestamp"] else datetime.now(),
1666
+ canonical_name=row["canonical_name"], quantity=row["quantity"],
1667
+ unit=row["unit"], total_price=row["total_price"],
1668
+ currency=row["currency"], source_type=row["source_type"],
1669
+ store_name=row["store_name"], raw_text=row["raw_text"],
1670
+ source_file_path=row["source_file_path"],
1671
+ confirmed=bool(row["confirmed"]),
1672
+ )
1673
+
1674
+
1675
+ def _row_to_reconciliation(row: sqlite3.Row) -> ReconciliationEvent:
1676
+ return ReconciliationEvent(
1677
+ event_id=row["event_id"],
1678
+ timestamp=datetime.fromisoformat(row["timestamp"]) if row["timestamp"] else datetime.now(),
1679
+ canonical_name=row["canonical_name"],
1680
+ planned_action=row["planned_action"],
1681
+ actual_action=row["actual_action"],
1682
+ quantity=row["quantity"],
1683
+ unit=row["unit"],
1684
+ price_paid=row["price_paid"],
1685
+ planned_price=row["planned_price"],
1686
+ substituted_with=row["substituted_with"],
1687
+ notes=row["notes"],
1688
+ source=row["source"],
1689
+ )
1690
+
1691
+
1692
+ def _row_to_preference(row: sqlite3.Row) -> PreferenceSignal:
1693
+ return PreferenceSignal(
1694
+ signal_id=row["signal_id"],
1695
+ canonical_name=row["canonical_name"],
1696
+ signal_type=row["signal_type"],
1697
+ value=row["value"],
1698
+ confidence=row["confidence"],
1699
+ source=row["source"],
1700
+ created_at=datetime.fromisoformat(row["created_at"]) if row["created_at"] else datetime.now(),
1701
+ updated_at=datetime.fromisoformat(row["updated_at"]) if row["updated_at"] else datetime.now(),
1702
+ )
1703
+
1704
+
1705
+ def _row_to_inventory_event(row: sqlite3.Row) -> InventoryEvent:
1706
+ return InventoryEvent(
1707
+ event_id=row["event_id"],
1708
+ timestamp=datetime.fromisoformat(row["timestamp"]) if row["timestamp"] else datetime.now(),
1709
+ lot_id=row["lot_id"] or "",
1710
+ canonical_name=row["canonical_name"] or "",
1711
+ action=row["action"],
1712
+ quantity_before=row["quantity_before"] if row["quantity_before"] is not None else None,
1713
+ quantity_after=row["quantity_after"] if row["quantity_after"] is not None else None,
1714
+ quantity_delta=row["quantity_delta"] if row["quantity_delta"] is not None else None,
1715
+ unit=row["unit"] or "",
1716
+ location_from=row["location_from"],
1717
+ location_to=row["location_to"],
1718
+ source=row["source"] or "manual",
1719
+ notes=row["notes"],
1720
+ )
1721
+
1722
+
1723
+ from shopstack.schemas.models import ToolCall as _ToolCall # noqa: E402 — circular import
1724
+
1725
+
1726
+ def _coerce_tool_call_payload(d: dict) -> dict:
1727
+ if not isinstance(d, dict):
1728
+ return {
1729
+ "tool_name": "respond",
1730
+ "args": {"message": str(d)},
1731
+ "success": False,
1732
+ "error": "Invalid tool call payload",
1733
+ }
1734
+
1735
+ tool_name = d.get("tool_name") or d.get("tool")
1736
+ if not tool_name:
1737
+ return {
1738
+ "tool_name": "respond",
1739
+ "args": {"message": "Missing tool"},
1740
+ "success": False,
1741
+ "error": "Missing tool name",
1742
+ }
1743
+
1744
+ args = d.get("args")
1745
+ if not isinstance(args, dict):
1746
+ args = {}
1747
+
1748
+ result = d.get("result")
1749
+ if result is not None and not isinstance(result, dict):
1750
+ result = {"value": result}
1751
+
1752
+ return {
1753
+ "tool_name": str(tool_name),
1754
+ "args": args,
1755
+ "result": result,
1756
+ "success": bool(d.get("success", False)),
1757
+ "error": d.get("error"),
1758
+ "requires_confirmation": bool(d.get("requires_confirmation", True)),
1759
+ "confirmed": bool(d.get("confirmed", False)),
1760
+ }
1761
+
1762
+
1763
+ def _dict_to_tc(d: dict) -> _ToolCall:
1764
+ return _ToolCall(**_coerce_tool_call_payload(d))
shopstack/planner/__init__.py ADDED
File without changes
shopstack/planner/engine.py ADDED
@@ -0,0 +1,632 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import logging
5
+ import time
6
+ from html import escape
7
+ from typing import Any
8
+
9
+ from shopstack.config import settings
10
+ from shopstack.cost_tracker import CostRecord, CostTracker, estimate_cost_usd, estimate_model_tier
11
+ from shopstack.persistence.database import Database
12
+ from shopstack.planner.parser import parse_tool_calls_with_diagnostics
13
+ from shopstack.providers.registry import ProviderRegistry
14
+ from shopstack.tools.registry import ToolRegistry
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ TOOL_ACTIONS_HELP: dict[str, str] = {
19
+ "add_inventory_item": "Added {canonical_name} to inventory.",
20
+ "consume_inventory_item": "Consumed {quantity} of lot {lot_id}.",
21
+ "update_inventory_item": "Updated lot {lot_id}.",
22
+ "move_inventory_item": "Moved lot {lot_id} to {to_location_id}.",
23
+ "find_item": "",
24
+ "create_or_update_shopping_list": "Updated shopping list.",
25
+ "compare_visible_item_to_inventory": "",
26
+ "record_price_observation": "Recorded price for {canonical_name}.",
27
+ "get_use_soon_items": "",
28
+ "get_next_buy_suggestions": "",
29
+ "respond": "",
30
+ }
31
+
32
+
33
+ class PlannerEngine:
34
+ MAX_TOOL_CALLS_PER_RUN = 8
35
+
36
+ def __init__(
37
+ self,
38
+ db: Database,
39
+ tool_registry: ToolRegistry,
40
+ provider_registry: ProviderRegistry,
41
+ ):
42
+ self._db = db
43
+ self._tools = tool_registry
44
+ self._providers = provider_registry
45
+ self._cost_tracker = CostTracker(budget_limit=settings.cost_budget_limit)
46
+
47
+ @property
48
+ def available(self) -> bool:
49
+ provider = self._providers.planner
50
+ if provider is None:
51
+ return False
52
+ return getattr(provider, "available", False)
53
+
54
+ def process(self, question: str, compact_tools: bool | None = None) -> str:
55
+ from shopstack.planner.prompts import build_planner_prompt, build_system_prompt
56
+
57
+ provider = self._providers.planner
58
+ if self._cost_guarded():
59
+ return self._budget_blocked_html()
60
+ if provider is None or not getattr(provider, "available", False):
61
+ return (
62
+ "<div class='stat-card'>Planner not available. "
63
+ "Set SHOPSTACK_PLANNER_BACKEND=local to use a local model, "
64
+ "or SHOPSTACK_PLANNER_BACKEND=openai for OpenAI.</div>"
65
+ )
66
+
67
+ if compact_tools is None:
68
+ compact_tools = settings.planner_compact_tools
69
+ prompt = build_planner_prompt(question, self._db, tool_registry=self._tools, compact_tools=compact_tools)
70
+ system_prompt = build_system_prompt(self._db, tool_registry=self._tools, compact_tools=compact_tools)
71
+ provider_meta: dict[str, Any] = {}
72
+ parser_meta: dict[str, Any] = {}
73
+
74
+ try:
75
+ started = time.monotonic()
76
+ if hasattr(provider, "plan"):
77
+ result = provider.plan({
78
+ "prompt": prompt,
79
+ "system": system_prompt,
80
+ "question": question,
81
+ })
82
+ planner_call_ms = round((time.monotonic() - started) * 1000, 2)
83
+ tool_calls, parser_meta = self._parse_tool_calls_from_result(result)
84
+ provider_meta = self._provider_call_meta(
85
+ provider,
86
+ result=result,
87
+ call_latency_ms=planner_call_ms,
88
+ question=question,
89
+ prompt=prompt,
90
+ )
91
+ else:
92
+ complete_fn = getattr(provider, "complete", None)
93
+ plan_result = complete_fn(prompt) if callable(complete_fn) else {"text": ""}
94
+ if isinstance(plan_result, dict):
95
+ result = plan_result
96
+ planner_call_ms = round((time.monotonic() - started) * 1000, 2)
97
+ raw_text = str(plan_result.get("text", ""))
98
+ self._record_provider_cost(plan_result)
99
+ provider_meta = self._provider_call_meta(
100
+ provider,
101
+ result=plan_result,
102
+ call_latency_ms=planner_call_ms,
103
+ question=question,
104
+ prompt=prompt,
105
+ )
106
+ if self._cost_guarded():
107
+ return self._budget_blocked_html()
108
+ if not raw_text:
109
+ provider_meta["outcome"] = "empty_llm_text"
110
+ return "<div class='stat-card'>Planner returned an empty response.</div>"
111
+ tool_calls, parser_meta = self._parse_tool_calls_from_result(raw_text)
112
+ else:
113
+ planner_call_ms = round((time.monotonic() - started) * 1000, 2)
114
+ tool_calls, parser_meta = self._parse_tool_calls_from_result(str(plan_result))
115
+ provider_meta = self._provider_call_meta(
116
+ provider,
117
+ result=None,
118
+ raw_output=plan_result,
119
+ call_latency_ms=planner_call_ms,
120
+ question=question,
121
+ prompt=prompt,
122
+ )
123
+
124
+ except Exception as e:
125
+ logger.warning("Planner call failed", exc_info=True)
126
+ return f"<div class='stat-card'><div style='color:var(--red);'>Planner error: {escape(str(e))}</div></div>"
127
+
128
+ provider_meta["parser"] = parser_meta
129
+ outcomes, execution_meta = self._execute_tool_calls(tool_calls)
130
+ provider_meta["execution"] = execution_meta
131
+ # kept intentionally for UI/debug surfacing through raw trace consumers
132
+ return self._format_outcomes(outcomes, question)
133
+
134
+ def process_structured(self, question: str, compact_tools: bool | None = None) -> dict[str, Any]:
135
+ """Process a question and return a structured dictionary instead of HTML prose."""
136
+ from shopstack.planner.prompts import build_planner_prompt, build_system_prompt
137
+
138
+ provider = self._providers.planner
139
+ if provider is None or not getattr(provider, "available", False):
140
+ return {"error": "Planner not available", "type": "error"}
141
+
142
+ if compact_tools is None:
143
+ compact_tools = settings.planner_compact_tools
144
+ prompt = build_planner_prompt(question, self._db, tool_registry=self._tools, compact_tools=compact_tools)
145
+ system_prompt = build_system_prompt(self._db, tool_registry=self._tools, compact_tools=compact_tools)
146
+ parser_meta: dict[str, Any] = {}
147
+ provider_meta: dict[str, Any] = {}
148
+
149
+ if self._cost_guarded():
150
+ return {"error": "Budget limit reached", "type": "error"}
151
+
152
+ try:
153
+ started = time.monotonic()
154
+ if hasattr(provider, "plan"):
155
+ result = provider.plan({
156
+ "prompt": prompt,
157
+ "system": system_prompt,
158
+ "question": question,
159
+ })
160
+ planner_call_ms = round((time.monotonic() - started) * 1000, 2)
161
+ tool_calls, parser_meta = self._parse_tool_calls_from_result(result)
162
+ provider_meta = self._provider_call_meta(
163
+ provider,
164
+ result=result,
165
+ call_latency_ms=planner_call_ms,
166
+ question=question,
167
+ prompt=prompt,
168
+ )
169
+ else:
170
+ complete_fn = getattr(provider, "complete", None)
171
+ plan_result = complete_fn(prompt) if callable(complete_fn) else {"text": ""}
172
+ if isinstance(plan_result, dict):
173
+ planner_call_ms = round((time.monotonic() - started) * 1000, 2)
174
+ raw_text = str(plan_result.get("text", ""))
175
+ self._record_provider_cost(plan_result)
176
+ provider_meta = self._provider_call_meta(
177
+ provider,
178
+ result=plan_result,
179
+ call_latency_ms=planner_call_ms,
180
+ question=question,
181
+ prompt=prompt,
182
+ )
183
+ if self._cost_guarded():
184
+ return {"error": "Budget limit reached", "type": "error"}
185
+ if not raw_text:
186
+ return {"error": "Planner returned an empty response.", "type": "error"}
187
+ tool_calls, parser_meta = self._parse_tool_calls_from_result(raw_text)
188
+ else:
189
+ planner_call_ms = round((time.monotonic() - started) * 1000, 2)
190
+ tool_calls, parser_meta = self._parse_tool_calls_from_result(str(plan_result))
191
+ provider_meta = self._provider_call_meta(
192
+ provider,
193
+ result=None,
194
+ raw_output=plan_result,
195
+ call_latency_ms=planner_call_ms,
196
+ question=question,
197
+ prompt=prompt,
198
+ )
199
+
200
+ except Exception as e:
201
+ logger.warning("Planner call failed", exc_info=True)
202
+ return {"error": f"Planner error: {str(e)}", "type": "error"}
203
+
204
+ if not tool_calls:
205
+ return {"error": "Planner returned an empty response.", "type": "error"}
206
+
207
+ outcomes, execution_meta = self._execute_tool_calls(tool_calls)
208
+ provider_meta["parser"] = parser_meta
209
+ provider_meta["execution"] = execution_meta
210
+
211
+ return {
212
+ "tool_calls": tool_calls,
213
+ "outcomes": outcomes,
214
+ "type": "tool_calls",
215
+ "debug": {
216
+ "provider": provider_meta,
217
+ "parser": parser_meta,
218
+ "execution": execution_meta,
219
+ },
220
+ }
221
+
222
+ @property
223
+ def session_cost(self) -> dict[str, Any]:
224
+ """Return the current session cost summary."""
225
+ return self._cost_tracker.summary()
226
+
227
+ def _record_provider_cost(self, result: dict[str, Any]) -> None:
228
+ """Extract usage/cost from a provider result dict and record it."""
229
+ model_key = str(result.get("model") or result.get("model_key") or "unknown")
230
+ usage = result.get("usage") or {}
231
+ input_tokens = int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
232
+ output_tokens = int(usage.get("completion_tokens") or usage.get("output_tokens") or 0)
233
+ if not output_tokens and usage.get("total_tokens"):
234
+ output_tokens = int(usage["total_tokens"])
235
+ latency_ms = result.get("latency_ms")
236
+ if isinstance(latency_ms, (int, float)):
237
+ latency_ms = float(latency_ms)
238
+ elif isinstance(result.get("cost"), dict):
239
+ latency_ms = result["cost"].get("latency_ms")
240
+ if input_tokens or output_tokens:
241
+ self._record_cost(model_key, input_tokens, output_tokens, latency_ms)
242
+
243
+ def _provider_call_meta(
244
+ self,
245
+ provider: Any,
246
+ result: Any = None,
247
+ call_latency_ms: float | None = None,
248
+ question: str = "",
249
+ prompt: str = "",
250
+ raw_output: Any = None,
251
+ ) -> dict[str, Any]:
252
+ if result is None:
253
+ result = {}
254
+ usage: dict[str, Any] = {}
255
+ if isinstance(result, dict):
256
+ usage = result.get("usage") or {}
257
+ if not isinstance(usage, dict):
258
+ usage = {}
259
+
260
+ cost_payload = result.get("cost") if isinstance(result, dict) else {}
261
+ if not isinstance(cost_payload, dict):
262
+ cost_payload = {}
263
+
264
+ input_tokens = 0
265
+ output_tokens = 0
266
+ if usage:
267
+ input_tokens = int(usage.get("prompt_tokens") or usage.get("input_tokens") or 0)
268
+ output_tokens = int(usage.get("completion_tokens") or usage.get("output_tokens") or 0)
269
+ if not output_tokens and usage.get("total_tokens"):
270
+ output_tokens = int(usage["total_tokens"])
271
+
272
+ model_key = None
273
+ if isinstance(result, dict):
274
+ model_key = result.get("model") or result.get("model_key")
275
+ if not model_key:
276
+ model_key = getattr(provider, "_model", None)
277
+ if not model_key:
278
+ model_key = getattr(provider, "_model_name", None)
279
+ if not model_key:
280
+ model_key = getattr(provider, "model_id", None) or getattr(provider, "name", "unknown")
281
+
282
+ latency_ms = call_latency_ms
283
+ if isinstance(cost_payload, dict) and isinstance(cost_payload.get("latency_ms"), (int, float)):
284
+ latency_ms = float(cost_payload["latency_ms"])
285
+
286
+ if latency_ms is None:
287
+ latency_ms = (
288
+ getattr(provider, "last_latency_ms", None)
289
+ or getattr(provider, "_last_latency_ms", None)
290
+ or getattr(provider, "latency_ms", None)
291
+ or getattr(provider, "_last_response_latency_ms", None)
292
+ )
293
+
294
+ if latency_ms is not None:
295
+ latency_ms = round(float(latency_ms), 2)
296
+
297
+ return {
298
+ "provider": getattr(provider, "name", "unknown"),
299
+ "model": str(model_key),
300
+ "backend": (
301
+ getattr(provider, "_backend", None)
302
+ or getattr(provider, "backend", None)
303
+ or ""
304
+ ),
305
+ "latency_ms": latency_ms,
306
+ "prompt_length": len(prompt or ""),
307
+ "question_length": len(question or ""),
308
+ "input_tokens": input_tokens,
309
+ "output_tokens": output_tokens,
310
+ "usage": usage,
311
+ "cost_usd": cost_payload.get("usd"),
312
+ "raw_output_type": type(raw_output if raw_output is not None else result).__name__,
313
+ }
314
+
315
+ def _record_cost(self, model_key: str, input_tokens: int, output_tokens: int, latency_ms: float | None = None) -> None:
316
+ """Record a cost entry from a provider call."""
317
+ cost = estimate_cost_usd(model_key, input_tokens, output_tokens)
318
+ tier = estimate_model_tier(input_tokens, item_count=0)
319
+ record = CostRecord(
320
+ model=model_key,
321
+ input_tokens=input_tokens,
322
+ output_tokens=output_tokens,
323
+ cost_usd=cost,
324
+ tier=tier,
325
+ latency_ms=latency_ms,
326
+ )
327
+ self._cost_tracker = self._cost_tracker.add(record)
328
+
329
+ # Patterns that indicate potential injection or path traversal in tool args
330
+ _SUSPICIOUS_ARG_PATTERNS = (
331
+ "../", "..\\", "/etc/", "C:\\", "|", ";", "&&", "||", "`", "$(",
332
+ "__import__", "eval(", "exec(", "open(", "os.", "subprocess",
333
+ )
334
+
335
+ def _contains_suspicious_text(self, value: Any) -> str | None:
336
+ if isinstance(value, str):
337
+ lower_val = value.lower()
338
+ for pattern in self._SUSPICIOUS_ARG_PATTERNS:
339
+ if pattern.lower() in lower_val:
340
+ return pattern
341
+ return None
342
+ if isinstance(value, list | tuple | set):
343
+ for item in value:
344
+ match = self._contains_suspicious_text(item)
345
+ if match is not None:
346
+ return match
347
+ return None
348
+ if isinstance(value, dict):
349
+ for item in list(value.keys()) + list(value.values()):
350
+ match = self._contains_suspicious_text(item)
351
+ if match is not None:
352
+ return match
353
+ return None
354
+ return None
355
+
356
+ def _validate_args(self, tool: str, args: dict[str, Any]) -> str | None:
357
+ """Validate tool arguments for injection / path traversal / abuse.
358
+ Returns an error message string if validation fails, or None if clean.
359
+ """
360
+ for key, value in args.items():
361
+ match = self._contains_suspicious_text(value)
362
+ if match is not None:
363
+ return (
364
+ f"Rejected tool '{tool}' arg '{key}': "
365
+ f"value contains suspicious pattern '{match}'"
366
+ )
367
+ return None
368
+
369
+ def _parse_tool_calls_from_result(
370
+ self, result: str | list[Any] | dict[str, Any]
371
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
372
+ if isinstance(result, dict):
373
+ raw_tool_calls = result.get("tool_calls")
374
+ if isinstance(raw_tool_calls, list):
375
+ raw_tool_calls = [c for c in raw_tool_calls if isinstance(c, dict)]
376
+ tool_calls, diagnostics = parse_tool_calls_with_diagnostics(
377
+ json.dumps(raw_tool_calls, default=str)
378
+ )
379
+ diagnostics["source"] = "planner_plan_tool_calls_key"
380
+ return tool_calls, diagnostics
381
+ tool_name = result.get("tool")
382
+ if tool_name and isinstance(tool_name, str):
383
+ raw = json.dumps([{"tool": tool_name, "args": result.get("args", {})}])
384
+ tool_calls, diagnostics = parse_tool_calls_with_diagnostics(raw)
385
+ diagnostics["source"] = "planner_plan_tool_object"
386
+ return tool_calls, diagnostics
387
+
388
+ raw = str(result.get("text", ""))
389
+ tool_calls, diagnostics = parse_tool_calls_with_diagnostics(raw)
390
+ diagnostics["source"] = "planner_plan_text"
391
+ return tool_calls, diagnostics
392
+
393
+ if isinstance(result, list):
394
+ try:
395
+ raw = json.dumps(result, default=str)
396
+ except Exception:
397
+ raw = str(result)
398
+ tool_calls, diagnostics = parse_tool_calls_with_diagnostics(raw)
399
+ diagnostics["source"] = "planner_plan_list"
400
+ return tool_calls, diagnostics
401
+
402
+ tool_calls, diagnostics = parse_tool_calls_with_diagnostics(str(result or ""))
403
+ diagnostics["source"] = "planner_plan_other"
404
+ return tool_calls, diagnostics
405
+
406
+ def _execute_tool_calls(
407
+ self, tool_calls: list[dict[str, Any]]
408
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
409
+ if self._cost_guarded():
410
+ return (
411
+ [{
412
+ "tool": "respond",
413
+ "success": False,
414
+ "error": self._cost_blocked_reason(),
415
+ }],
416
+ {
417
+ "tool_calls_requested": len(tool_calls),
418
+ "tool_calls_executed": 0,
419
+ "tool_calls_failed": 0,
420
+ "tool_calls_truncated": 0,
421
+ "tool_runs": [],
422
+ "cost_blocked": True,
423
+ },
424
+ )
425
+
426
+ results: list[dict[str, Any]] = []
427
+ limited = tool_calls[: self.MAX_TOOL_CALLS_PER_RUN]
428
+ execution: dict[str, Any] = {
429
+ "tool_calls_requested": len(tool_calls),
430
+ "tool_calls_executed": 0,
431
+ "tool_calls_failed": 0,
432
+ "tool_calls_truncated": max(0, len(tool_calls) - self.MAX_TOOL_CALLS_PER_RUN),
433
+ "tool_runs": [],
434
+ "cost_blocked": False,
435
+ }
436
+
437
+ if len(tool_calls) > self.MAX_TOOL_CALLS_PER_RUN:
438
+ results.append({
439
+ "tool": "respond",
440
+ "success": True,
441
+ "message": (
442
+ f"Planner requested {len(tool_calls)} actions; "
443
+ f"executing first {self.MAX_TOOL_CALLS_PER_RUN} for safety."
444
+ ),
445
+ })
446
+
447
+ for tc in limited:
448
+ run = {"tool": tc.get("tool", "respond"), "status": "started"}
449
+ started = time.monotonic()
450
+
451
+ tool = tc.get("tool", "respond")
452
+ args = tc.get("args", {})
453
+ if tool == "respond":
454
+ elapsed_ms = round((time.monotonic() - started) * 1000, 2)
455
+ run["status"] = "respond"
456
+ run["latency_ms"] = elapsed_ms
457
+ execution["tool_runs"].append(run)
458
+ execution["tool_calls_executed"] += 1
459
+ msg = args.get("message", "")
460
+ results.append({
461
+ "tool": "respond",
462
+ "success": True,
463
+ "message": msg,
464
+ })
465
+ continue
466
+
467
+ validation_error = self._validate_args(tool, args)
468
+ if validation_error is not None:
469
+ elapsed_ms = round((time.monotonic() - started) * 1000, 2)
470
+ run["status"] = "validation_failed"
471
+ run["error"] = validation_error
472
+ run["latency_ms"] = elapsed_ms
473
+ execution["tool_runs"].append(run)
474
+ execution["tool_calls_failed"] += 1
475
+ execution["tool_calls_executed"] += 1
476
+ results.append({
477
+ "tool": tool,
478
+ "success": False,
479
+ "error": validation_error,
480
+ "latency_ms": elapsed_ms,
481
+ })
482
+ continue
483
+
484
+ tool_spec = self._tools._find_tool_spec(tool)
485
+ if tool_spec is not None and tool_spec.mutability == "write":
486
+ requires_confirmation = bool(tool_spec.needs_confirmation) or (
487
+ not settings.planner_allow_writes and tool != "create_or_update_shopping_list"
488
+ )
489
+ if requires_confirmation:
490
+ elapsed_ms = round((time.monotonic() - started) * 1000, 2)
491
+ if tool == "create_or_update_shopping_list":
492
+ item_count = 0
493
+ if isinstance(args.get("items"), list):
494
+ item_count = len(args.get("items", []))
495
+ summary = f"plan {item_count} shopping list item(s)"
496
+ elif "canonical_name" in args:
497
+ summary = f"modify '{args['canonical_name']}'"
498
+ else:
499
+ summary = f"apply '{tool}'"
500
+
501
+ reason = (
502
+ f"Planner write blocked by safety policy: {summary}. "
503
+ "Review and confirm this action in the relevant screen."
504
+ )
505
+ run["status"] = "blocked_by_policy"
506
+ run["error"] = reason
507
+ run["latency_ms"] = elapsed_ms
508
+ execution["tool_runs"].append(run)
509
+ execution["tool_calls_executed"] += 1
510
+ execution["tool_calls_failed"] += 1
511
+ results.append({
512
+ "tool": tool,
513
+ "success": False,
514
+ "error": reason,
515
+ "latency_ms": elapsed_ms,
516
+ })
517
+ continue
518
+
519
+ try:
520
+ outcome = self._tools.execute(tool, **args)
521
+ elapsed_ms = round((time.monotonic() - started) * 1000, 2)
522
+ tool_success = outcome.get("success", False)
523
+ result = {
524
+ "tool": tool,
525
+ "success": bool(tool_success),
526
+ "result": outcome.get("result", outcome),
527
+ "error": outcome.get("error"),
528
+ "latency_ms": elapsed_ms,
529
+ }
530
+ execution["tool_calls_executed"] += 1
531
+ if not tool_success:
532
+ run["status"] = "tool_failed"
533
+ run["error"] = outcome.get("error")
534
+ execution["tool_calls_failed"] += 1
535
+ else:
536
+ run["status"] = "succeeded"
537
+ run["latency_ms"] = elapsed_ms
538
+ run["success"] = bool(tool_success)
539
+ execution["tool_runs"].append(run)
540
+ results.append(result)
541
+ except Exception as e:
542
+ elapsed_ms = round((time.monotonic() - started) * 1000, 2)
543
+ run["status"] = "exception"
544
+ run["error"] = str(e)
545
+ run["latency_ms"] = elapsed_ms
546
+ execution["tool_runs"].append(run)
547
+ execution["tool_calls_executed"] += 1
548
+ execution["tool_calls_failed"] += 1
549
+ results.append({
550
+ "tool": tool,
551
+ "success": False,
552
+ "error": str(e),
553
+ "latency_ms": elapsed_ms,
554
+ })
555
+
556
+ if self._cost_guarded():
557
+ blocked_msg = self._cost_blocked_reason()
558
+ execution["cost_blocked"] = True
559
+ results.append({
560
+ "tool": "respond",
561
+ "success": False,
562
+ "error": blocked_msg,
563
+ })
564
+ execution["tool_runs"].append({
565
+ "tool": "respond",
566
+ "status": "cost_blocked",
567
+ "error": blocked_msg,
568
+ "latency_ms": 0.0,
569
+ })
570
+ break
571
+
572
+ return results, execution
573
+
574
+ def _cost_guarded(self) -> bool:
575
+ return self._cost_tracker.over_budget
576
+
577
+ def _cost_blocked_reason(self) -> str:
578
+ summary = self.session_cost
579
+ return (
580
+ "Cost budget exceeded. "
581
+ f"Budget: ${summary['budget_limit']:.2f}, "
582
+ f"Spent: ${summary['total_cost']:.2f}."
583
+ )
584
+
585
+ def _budget_blocked_html(self) -> str:
586
+ return (
587
+ "<div class='stat-card'>"
588
+ f"<div style='font-weight:600;color:var(--red);'>Cost budget blocked</div>"
589
+ f"<div>{escape(self._cost_blocked_reason())}</div>"
590
+ "</div>"
591
+ )
592
+
593
+ def _format_outcomes(
594
+ self, outcomes: list[dict[str, Any]], original_question: str
595
+ ) -> str:
596
+ html_parts: list[str] = []
597
+ for outcome in outcomes:
598
+ tool = outcome["tool"]
599
+ success = outcome["success"]
600
+ if tool == "respond":
601
+ msg = outcome.get("message", "")
602
+ if msg:
603
+ html_parts.append(
604
+ f"<div style='padding:8px;margin:4px 0;border-left:3px solid var(--accent);'>{escape(str(msg))}</div>"
605
+ )
606
+ continue
607
+ action = TOOL_ACTIONS_HELP.get(tool, f"Ran {tool}.")
608
+ _result_data = outcome.get("result") or outcome.get("error", "")
609
+ if success:
610
+ if action:
611
+ html_parts.append(
612
+ f"<div style='padding:6px;margin:2px 0;color:var(--text-main);'>"
613
+ f"<span style='color:var(--green);'>&#10003;</span> {action}</div>"
614
+ )
615
+ else:
616
+ err = outcome.get("error", "Unknown error")
617
+ html_parts.append(
618
+ f"<div style='padding:6px;margin:2px 0;color:var(--red);'>"
619
+ f"&#10007; {escape(str(tool))}: {escape(str(err))}</div>"
620
+ )
621
+
622
+ if not html_parts:
623
+ return "<div class='stat-card'>No actions taken.</div>"
624
+
625
+ title = f"{settings.app_name} AI"
626
+ body = "".join(html_parts)
627
+ return (
628
+ f"<div class='stat-card'>"
629
+ f"<div style='font-weight:600;margin-bottom:8px;'>{title}</div>"
630
+ f"{body}"
631
+ f"</div>"
632
+ )