Spaces:
Running
Running
Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- README.md +299 -6
- app.py +204 -0
- packages.txt +1 -0
- requirements.txt +10 -0
- setup.py +40 -0
- shopstack/__init__.py +0 -0
- shopstack/_legacy_decisions.py +88 -0
- shopstack/_version.py +1 -0
- shopstack/app_context.py +105 -0
- shopstack/basket/__init__.py +4 -0
- shopstack/basket/constraints.py +28 -0
- shopstack/basket/models.py +38 -0
- shopstack/basket/scoring.py +39 -0
- shopstack/basket/service.py +205 -0
- shopstack/catalog/models.py +33 -0
- shopstack/catalog/service.py +22 -0
- shopstack/config.py +112 -0
- shopstack/cost_tracker.py +117 -0
- shopstack/data/__init__.py +0 -0
- shopstack/data/seed_demo.py +79 -0
- shopstack/decisions/__init__.py +84 -0
- shopstack/decisions/rules.py +733 -0
- shopstack/decisions/types.py +50 -0
- shopstack/domain/__init__.py +82 -0
- shopstack/market/__init__.py +35 -0
- shopstack/market/analytics.py +212 -0
- shopstack/market/basket.py +495 -0
- shopstack/market/combo.py +80 -0
- shopstack/market/comparison.py +98 -0
- shopstack/market/metadata.py +79 -0
- shopstack/market/normalization.py +447 -0
- shopstack/market/schema.py +93 -0
- shopstack/market/sources/__init__.py +79 -0
- shopstack/market/sources/_adapter.py +70 -0
- shopstack/market/sources/_blinkit_adapter.py +84 -0
- shopstack/market/sources/_comparison.py +121 -0
- shopstack/market/sources/_dmart_adapter.py +84 -0
- shopstack/market/sources/_json_adapter.py +218 -0
- shopstack/market/sources/_registry.py +117 -0
- shopstack/market/sources/_repository.py +202 -0
- shopstack/market/sources/_swiggy_adapter.py +44 -0
- shopstack/market/sources/_zepto_adapter.py +84 -0
- shopstack/market/sources/swiggy.py +279 -0
- shopstack/memory/waste_patterns.py +104 -0
- shopstack/model_registry.py +746 -0
- shopstack/module_registry.py +362 -0
- shopstack/persistence/__init__.py +0 -0
- shopstack/persistence/database.py +1764 -0
- shopstack/planner/__init__.py +0 -0
- shopstack/planner/engine.py +632 -0
README.md
CHANGED
|
@@ -1,13 +1,306 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
colorTo: blue
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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;'>₹{price:.0f}</td>"
|
| 95 |
+
else:
|
| 96 |
+
price_cells += f"<td style='text-align:right;padding:4px;'>₹{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);'>✓</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"✗ {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 |
+
)
|