Zappandy commited on
Commit
dae60e5
·
0 Parent(s):

Deploy to HF Space

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .env.example +22 -0
  2. .gitignore +240 -0
  3. .python-version +1 -0
  4. AGENTS.md +290 -0
  5. Dockerfile +24 -0
  6. README.md +653 -0
  7. app.py +706 -0
  8. archive/legacy/asr.py +26 -0
  9. archive/legacy/database.py +357 -0
  10. archive/legacy/finetune_receipt.py +184 -0
  11. archive/legacy/graph.py +103 -0
  12. archive/legacy/inventory_manager.py +112 -0
  13. archive/legacy/llm.py +161 -0
  14. archive/legacy/ocr.py +86 -0
  15. archive/legacy/orchestrator.py +87 -0
  16. archive/legacy/po_check.py +41 -0
  17. archive/legacy/receipt_parser.py +113 -0
  18. archive/legacy/reorder_agent.py +143 -0
  19. archive/legacy/reporting_agent.py +47 -0
  20. archive/legacy/state.py +35 -0
  21. archive/legacy/translate.py +75 -0
  22. archive/legacy/vector_store.py +167 -0
  23. benchmarks/benchmark_receipt_models.py +124 -0
  24. data/.gitkeep +0 -0
  25. data/finetune/receipt_examples.jsonl +10 -0
  26. docs/deployment_setup.md +269 -0
  27. docs/plan_half_baked_features.md +97 -0
  28. docs/plan_react_agent.md +110 -0
  29. docs/plan_voice_command_agent.md +120 -0
  30. dukaan_saathi/__init__.py +0 -0
  31. dukaan_saathi/agent/__init__.py +0 -0
  32. dukaan_saathi/agent/agent.py +96 -0
  33. dukaan_saathi/agent/react_agent.py +85 -0
  34. dukaan_saathi/agent/tools.py +191 -0
  35. dukaan_saathi/config.py +24 -0
  36. dukaan_saathi/integrations/__init__.py +0 -0
  37. dukaan_saathi/integrations/command_nlu.py +48 -0
  38. dukaan_saathi/integrations/hf_inference_receipt.py +107 -0
  39. dukaan_saathi/integrations/hub_traces.py +71 -0
  40. dukaan_saathi/integrations/llamacpp_llm.py +159 -0
  41. dukaan_saathi/integrations/llamacpp_receipt.py +116 -0
  42. dukaan_saathi/integrations/modal_receipt.py +140 -0
  43. dukaan_saathi/integrations/modal_receipt_llm.py +137 -0
  44. dukaan_saathi/integrations/speech.py +97 -0
  45. dukaan_saathi/integrations/vision.py +33 -0
  46. dukaan_saathi/parsers/__init__.py +0 -0
  47. dukaan_saathi/parsers/receipt_correction.py +234 -0
  48. dukaan_saathi/parsers/receipt_text.py +375 -0
  49. dukaan_saathi/parsers/stock_command.py +123 -0
  50. dukaan_saathi/schemas.py +0 -0
.env.example ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Do not commit real values.
2
+ # scripts/modal_deploy.sh writes real endpoint URLs to .env.
3
+
4
+ # HF Hub
5
+ HF_TOKEN= # write token for model push (modal run ...::push); not needed at inference if model is public
6
+ HF_RECEIPT_MODEL_REPO= # e.g. summerdevlin46/dukaan-saathi-receipt-lora
7
+
8
+ # Hugging Face Space persistence
9
+ # For the public Docker Space, set this to /data/dukaan.db only after enabling
10
+ # persistent storage in Space settings. Without persistent storage, omit it and
11
+ # the demo DB remains runtime-local.
12
+ DB_PATH=
13
+ RECEIPT_BACKEND=hf_inference
14
+
15
+ # Modal endpoints (written automatically by scripts/modal_deploy.sh)
16
+ MODAL_RECEIPT_ENDPOINT=
17
+ MINICPM_RECEIPT_ENDPOINT=
18
+ MODAL_RECEIPT_LLM_ENDPOINT=
19
+ MODAL_RECEIPT_PARSER_ENDPOINT=
20
+ MODAL_SPEECH_ENDPOINT=
21
+ SPEECH_ASR_ENDPOINT=
22
+ MODAL_NLU_ENDPOINT=
.gitignore ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ignore screenshots for now
2
+ *.jpeg
3
+ # Byte-compiled / optimized / DLL files
4
+ __pycache__/
5
+ *.py[codz]
6
+ *$py.class
7
+ *.pyc
8
+ .DS_Store
9
+
10
+ # data stuff
11
+ data/*.db
12
+ data/*.sqlite
13
+ data/*.sqlite3
14
+ config/
15
+ models/
16
+ data/runs/
17
+ data/finetune/generated/
18
+ *-receipt-lora/
19
+ *.gguf
20
+
21
+ # C extensions
22
+ *.so
23
+
24
+ # Distribution / packaging
25
+ .Python
26
+ build/
27
+ develop-eggs/
28
+ dist/
29
+ downloads/
30
+ eggs/
31
+ .eggs/
32
+ lib/
33
+ lib64/
34
+ parts/
35
+ sdist/
36
+ var/
37
+ wheels/
38
+ share/python-wheels/
39
+ *.egg-info/
40
+ .installed.cfg
41
+ *.egg
42
+ MANIFEST
43
+
44
+ # PyInstaller
45
+ # Usually these files are written by a python script from a template
46
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
47
+ *.manifest
48
+ *.spec
49
+
50
+ # Installer logs
51
+ pip-log.txt
52
+ pip-delete-this-directory.txt
53
+
54
+ # Unit test / coverage reports
55
+ htmlcov/
56
+ .tox/
57
+ .nox/
58
+ .coverage
59
+ .coverage.*
60
+ .cache
61
+ nosetests.xml
62
+ coverage.xml
63
+ *.cover
64
+ *.py.cover
65
+ .hypothesis/
66
+ .pytest_cache/
67
+ cover/
68
+ benchmarks/results/
69
+
70
+ # Translations
71
+ *.mo
72
+ *.pot
73
+
74
+ # Django stuff:
75
+ *.log
76
+ local_settings.py
77
+ db.sqlite3
78
+ db.sqlite3-journal
79
+
80
+ # Flask stuff:
81
+ instance/
82
+ .webassets-cache
83
+
84
+ # Scrapy stuff:
85
+ .scrapy
86
+
87
+ # Sphinx documentation
88
+ docs/_build/
89
+
90
+ # PyBuilder
91
+ .pybuilder/
92
+ target/
93
+
94
+ # Jupyter Notebook
95
+ .ipynb_checkpoints
96
+
97
+ # IPython
98
+ profile_default/
99
+ ipython_config.py
100
+
101
+ # pyenv
102
+ # For a library or package, you might want to ignore these files since the code is
103
+ # intended to run in multiple environments; otherwise, check them in:
104
+ # .python-version
105
+
106
+ # pipenv
107
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
108
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
109
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
110
+ # install all needed dependencies.
111
+ # Pipfile.lock
112
+
113
+ # UV
114
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
115
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
116
+ # commonly ignored for libraries.
117
+ # uv.lock
118
+
119
+ # poetry
120
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
121
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
122
+ # commonly ignored for libraries.
123
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
124
+ # poetry.lock
125
+ # poetry.toml
126
+
127
+ # pdm
128
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
129
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
130
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
131
+ # pdm.lock
132
+ # pdm.toml
133
+ .pdm-python
134
+ .pdm-build/
135
+
136
+ # pixi
137
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
138
+ # pixi.lock
139
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
140
+ # in the .venv directory. It is recommended not to include this directory in version control.
141
+ .pixi
142
+
143
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
144
+ __pypackages__/
145
+
146
+ # Celery stuff
147
+ celerybeat-schedule
148
+ celerybeat.pid
149
+
150
+ # Redis
151
+ *.rdb
152
+ *.aof
153
+ *.pid
154
+
155
+ # RabbitMQ
156
+ mnesia/
157
+ rabbitmq/
158
+ rabbitmq-data/
159
+
160
+ # ActiveMQ
161
+ activemq-data/
162
+
163
+ # SageMath parsed files
164
+ *.sage.py
165
+
166
+ # modal stuff
167
+ .modal.toml
168
+
169
+ # Environments
170
+ .env
171
+ .envrc
172
+ .venv
173
+ env/
174
+ venv/
175
+ ENV/
176
+ env.bak/
177
+ venv.bak/
178
+
179
+ # Spyder project settings
180
+ .spyderproject
181
+ .spyproject
182
+
183
+ # Rope project settings
184
+ .ropeproject
185
+
186
+ # mkdocs documentation
187
+ /site
188
+
189
+ # mypy
190
+ .mypy_cache/
191
+ .dmypy.json
192
+ dmypy.json
193
+
194
+ # Pyre type checker
195
+ .pyre/
196
+
197
+ # pytype static type analyzer
198
+ .pytype/
199
+
200
+ # Cython debug symbols
201
+ cython_debug/
202
+
203
+ # PyCharm
204
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
205
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
206
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
207
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
208
+ # .idea/
209
+
210
+ # Abstra
211
+ # Abstra is an AI-powered process automation framework.
212
+ # Ignore directories containing user credentials, local state, and settings.
213
+ # Learn more at https://abstra.io/docs
214
+ .abstra/
215
+
216
+ # Visual Studio Code
217
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
218
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
219
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
220
+ # you could uncomment the following to ignore the entire vscode folder
221
+ # .vscode/
222
+ # Temporary file for partial code execution
223
+ tempCodeRunnerFile.py
224
+
225
+ # Ruff stuff:
226
+ .ruff_cache/
227
+
228
+ # PyPI configuration file
229
+ .pypirc
230
+
231
+ # Marimo
232
+ marimo/_static/
233
+ marimo/_lsp/
234
+ __marimo__/
235
+
236
+ # Streamlit
237
+ .streamlit/secrets.toml
238
+ samples/
239
+ data/*.db-shm
240
+ data/*.db-wal
.python-version ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.13
AGENTS.md ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dukaan Saathi Agent Handoff
2
+
3
+ This file is the starting context for future agents working in this repository.
4
+ Use it to preserve the safety model, current architecture, and remaining task
5
+ priorities.
6
+
7
+ ## Hard Rules
8
+
9
+ - Use `uv` for all Python commands.
10
+ - Run `uv run scripts/smoke_test.sh` before finishing code changes.
11
+ - Do not commit secrets or local runtime files:
12
+ - `.env`
13
+ - Modal tokens
14
+ - Hugging Face tokens
15
+ - `data/*.db`
16
+ - `data/runs/`
17
+ - `models/`
18
+ - `__pycache__/`
19
+ - `.venv/`
20
+ - Do not remove Modal scripts, benchmark scripts, `AGENTS.md`, or
21
+ `.env.example`.
22
+ - Keep changes small and reviewable. Avoid broad rewrites unless the task
23
+ explicitly requires them.
24
+
25
+ ## Current App Shape
26
+
27
+ Dukaan Saathi is a phone-friendly inventory copilot for a small Indian kirana
28
+ store. It turns receipt photos, pasted receipt text, typed commands, and speech
29
+ transcripts into reviewable inventory drafts.
30
+
31
+ The core workflow is:
32
+
33
+ ```text
34
+ receipt photo / text command
35
+ -> AI draft or deterministic parser draft
36
+ -> editable rows or pending action
37
+ -> owner correction
38
+ -> owner approval
39
+ -> inventory update
40
+ -> reorder suggestion
41
+ ```
42
+
43
+ Important files:
44
+
45
+ - `app.py` is the FastAPI/static app entry point.
46
+ - `dukaan_saathi/ui/gradio_app.py` contains the Gradio UI path.
47
+ - `dukaan_saathi/agent/react_agent.py` is the active lean ReAct router.
48
+ - `dukaan_saathi/agent/tools.py` wraps parser, integration, and service tools.
49
+ - `dukaan_saathi/agent/agent.py` contains the heavier smolagents
50
+ `ToolCallingAgent`; it is not the primary UI path.
51
+ - `dukaan_saathi/services/inventory.py` is the required inventory write
52
+ boundary.
53
+ - `dukaan_saathi/storage.py` owns SQLite access and demo seed data.
54
+ - `dukaan_saathi/parsers/` contains deterministic fallback parsers.
55
+ - `dukaan_saathi/integrations/` contains thin HTTP/model integration clients.
56
+ - `modal_apps/` contains Modal-hosted model services and training jobs.
57
+ - `kirana_db.py` is the custom FastAPI UI adapter over the Dukaan storage
58
+ layer. It must preserve the same owner-approval safety rules while this path
59
+ is migrated toward the canonical inventory service boundary.
60
+
61
+ ## Safety Architecture
62
+
63
+ - Inventory writes must go through `dukaan_saathi/services/inventory.py`.
64
+ The current custom FastAPI path still writes through `kirana_db.py`, which
65
+ delegates to the Dukaan storage ledger; do not add new direct stock writes
66
+ elsewhere.
67
+ - Model output must never update inventory directly.
68
+ - Receipt extraction must populate an editable table first.
69
+ - Stock command parsing must produce a pending owner action first.
70
+ - Owner approval is required before stock changes.
71
+ - Receipt row approval and command approval are the only places that should
72
+ write inventory.
73
+ - Modal model services live in `modal_apps/`.
74
+ - App-side Modal integration must stay as a thin HTTP client in
75
+ `dukaan_saathi/integrations/`.
76
+ - Do not add heavy model inference directly to the Gradio or FastAPI runtime.
77
+ - ReAct traces should explain `Thought`, `Action`, and `Observation` steps, but
78
+ traces are audit/UI context only; they are not permission to write stock.
79
+ - ReAct is the orchestrator, not the model. It calls tools; model-backed tools
80
+ may call Modal, Hugging Face Inference, or local llama.cpp. The public Space
81
+ should rely on HF Inference plus optional Modal endpoints, not local model
82
+ servers.
83
+
84
+ When reviewing changes, flag any code path that bypasses owner approval before
85
+ stock changes.
86
+
87
+ ## Runtime And Environment
88
+
89
+ The public Hugging Face Space is:
90
+
91
+ ```text
92
+ https://huggingface.co/spaces/Zappandy/Kirana_AI
93
+ ```
94
+
95
+ The root `Dockerfile` deploys the Space frontend/backend by running
96
+ `uvicorn app:server`. Do not add a local model-server requirement to that path.
97
+
98
+ Use these local run paths:
99
+
100
+ ```bash
101
+ scripts/run_app.sh --hf-inference
102
+ scripts/run_app.sh --deterministic
103
+ scripts/run_app.sh --modal-llm
104
+ scripts/dev.sh --hf-inference
105
+ scripts/dev.sh --deterministic
106
+ scripts/dev.sh --modal-llm
107
+ ```
108
+
109
+ Supported receipt backends:
110
+
111
+ - `hf_inference` is the preferred public Hugging Face Space path.
112
+ - `modal_llm` calls the Modal-hosted receipt parser endpoint.
113
+ - `llamacpp` uses local llama.cpp servers only; do not require it for HF Spaces.
114
+ - `deterministic` uses rule-based parsers for smoke tests and offline
115
+ debugging.
116
+
117
+ Important environment variables:
118
+
119
+ ```text
120
+ RECEIPT_BACKEND
121
+ HF_RECEIPT_MODEL_REPO
122
+ HF_TOKEN
123
+ MODAL_RECEIPT_ENDPOINT
124
+ MODAL_RECEIPT_LLM_ENDPOINT
125
+ MODAL_SPEECH_ENDPOINT
126
+ SPEECH_ASR_ENDPOINT
127
+ MODAL_NLU_ENDPOINT # Qwen2.5-1.5B command slot extractor; optional, falls back to deterministic
128
+ DB_PATH
129
+ TRACE_DIR
130
+ ```
131
+
132
+ Never write real secret values into tracked files. Keep `.env.example` as the
133
+ only tracked env template.
134
+
135
+ SQLite defaults to `data/dukaan.db`. Local tests and demos may mutate this file.
136
+ Hosted HF Spaces state is not durable unless persistent storage is configured
137
+ and `DB_PATH` points at that persistent location.
138
+
139
+ For the public Space, use `DB_PATH=/data/dukaan.db` only when HF persistent
140
+ storage is enabled. Otherwise leave `DB_PATH` unset and treat the DB as
141
+ runtime-local demo state.
142
+
143
+ Runtime manifests, when enabled, belong under `data/runs/` and are local
144
+ evidence only. They must not contain tokens.
145
+
146
+ ## Model And Data Pipeline
147
+
148
+ Receipt image flow:
149
+
150
+ ```text
151
+ uploaded image
152
+ -> ReAct router
153
+ -> Modal MiniCPM-V OCR tool
154
+ -> raw receipt text
155
+ -> configured receipt parser tool/backend
156
+ -> editable receipt table
157
+ -> owner correction/approval
158
+ -> inventory service
159
+ -> SQLite stock ledger
160
+ ```
161
+
162
+ Receipt text backends:
163
+
164
+ - HF Inference calls the fine-tuned model in `HF_RECEIPT_MODEL_REPO`.
165
+ - Modal LLM calls `MODAL_RECEIPT_LLM_ENDPOINT`.
166
+ - llama.cpp uses local model servers.
167
+ - deterministic parser uses `dukaan_saathi/parsers/receipt_text.py`.
168
+
169
+ Speech flow:
170
+
171
+ ```text
172
+ audio
173
+ -> Modal speech endpoint
174
+ -> transcript
175
+ -> ReAct stock command tool
176
+ -> pending owner action
177
+ -> owner approval
178
+ -> inventory service
179
+ ```
180
+
181
+ Fine-tuning and model hosting stay in Modal:
182
+
183
+ - `modal_apps/receipt_data_generator.py` generates synthetic receipt examples.
184
+ - `modal_apps/receipt_llm_service.py` trains, serves, and pushes the receipt
185
+ parser model.
186
+ - `modal_apps/receipt_vlm_service.py` serves receipt image OCR.
187
+ - `modal_apps/speech_asr_service.py` serves speech transcription.
188
+
189
+ Use `uv run modal ...` for Modal commands.
190
+
191
+ ## Remaining Task Backlog
192
+
193
+ Prioritize safety and demo-critical correctness before polish.
194
+
195
+ ### Completed
196
+
197
+ - Voice stock commands parse to a pending action and require explicit owner
198
+ approval before stock writes.
199
+ - The custom FastAPI photo and voice paths use the lean ReAct router first, with
200
+ deterministic/configured fallback paths.
201
+ - Dashboard "Add to order" and "Offer to route" create pending order rows.
202
+ - Receipt rows are post-matched against inventory before display.
203
+ - Stock ledger deltas migrate to `REAL` for fractional quantities.
204
+ - Dashboard insights use deterministic inventory/expiry state.
205
+ - Orders support "Mark received" after approval.
206
+ - Analytics has a `7d` / `30d` / `90d` sales window.
207
+ - Modal photo/speech flows expose cold-start loading hints and `/api/warm`.
208
+ - ReAct agent trace surfaced in the UI as a collapsible "Agent reasoning" panel
209
+ on both the photo and voice result cards.
210
+ - Unknown-product commands extract a suggested name and quantity and offer an
211
+ inline "Add new product" form instead of showing a blank result.
212
+ - NLU slot extraction service (`modal_apps/command_nlu_service.py`) using
213
+ `Qwen/Qwen2.5-1.5B-Instruct`. Deployed; `MODAL_NLU_ENDPOINT` wired into
214
+ `.env` and HF Space secrets. `run_command_parse` tries NLU first, falls back
215
+ to ReAct/deterministic on failure or unknown intent.
216
+ - Mocked NLU tests cover the happy path, unknown-product path, and
217
+ missing-endpoint fallback (`smoke_tests/test_custom_app_safety.py`).
218
+ - `/api/warm` pings the NLU health endpoint (`nlu-health`) alongside the
219
+ receipt and speech endpoints.
220
+
221
+ ### Remaining
222
+
223
+ - Add and maintain tests for every approval gate and order/receipt transition.
224
+ - Migrate the custom FastAPI inventory writes from `kirana_db.py` toward
225
+ `dukaan_saathi/services/inventory.py`, or document the adapter boundary
226
+ explicitly until migration is done.
227
+ - Keep fractional quantity behavior covered in tests when changing receipt,
228
+ stock, reorder, or sales flows.
229
+
230
+ ## UI And Demo Constraints
231
+
232
+ - Keep the app phone-friendly.
233
+ - Avoid raw JSON or developer-looking output in user-facing UI unless it is
234
+ explicitly a trace/debug panel.
235
+ - Examples should map to seeded catalog items such as `Bun`, `OBM`,
236
+ `Happy Happy`, `Bingo (C)`, and `Parle (bulk)`.
237
+ - Do not reintroduce confusing/non-rendering Telugu examples.
238
+ - Do not use low-contrast dark text on green backgrounds.
239
+ - Keep the approval step visible. Do not bypass it to make the demo look more
240
+ automatic.
241
+
242
+ ## Review Guidelines
243
+
244
+ Flag these issues during review:
245
+
246
+ - Any inventory write that does not go through owner approval.
247
+ - Any direct database stock write outside `dukaan_saathi/services/inventory.py`.
248
+ - Receipt model output updating inventory directly.
249
+ - Voice command output updating inventory before confirmation.
250
+ - Secrets, tokens, local DB files, runtime manifests, or generated model files
251
+ being committed.
252
+ - Model inference added directly to Gradio/FastAPI runtime instead of an
253
+ integration client or Modal service.
254
+ - App-side Modal code becoming more than a thin HTTP client.
255
+ - Changes that delete Modal scripts, benchmark scripts, `AGENTS.md`, or
256
+ `.env.example`.
257
+ - Broad rewrites where a small targeted change would satisfy the task.
258
+
259
+ ## Commands And Checks
260
+
261
+ Install or sync dependencies:
262
+
263
+ ```bash
264
+ uv sync
265
+ ```
266
+
267
+ Run the required smoke test:
268
+
269
+ ```bash
270
+ uv run scripts/smoke_test.sh
271
+ ```
272
+
273
+ Useful focused checks:
274
+
275
+ ```bash
276
+ uv run python -m unittest smoke_tests.test_agent_ui_integration
277
+ uv run python -m pytest smoke_tests/test_receipt_parser_regression.py -q
278
+ uv run python -m pytest smoke_tests/test_receipt_correction.py -q
279
+ ```
280
+
281
+ Before finishing, inspect the diff:
282
+
283
+ ```bash
284
+ git status --short
285
+ git diff --stat
286
+ git diff -- AGENTS.md
287
+ ```
288
+
289
+ If there are unrelated user changes in the working tree, do not revert them.
290
+ Work around them or ask only when they block the task.
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+ ENV PORT=7860
6
+
7
+ WORKDIR /app
8
+
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ build-essential \
11
+ curl \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ COPY requirements.txt .
15
+ RUN pip install --no-cache-dir --upgrade pip \
16
+ && pip install --no-cache-dir -r requirements.txt
17
+
18
+ COPY . .
19
+
20
+ RUN mkdir -p data/runs /data
21
+
22
+ EXPOSE 7860
23
+
24
+ CMD ["uvicorn", "app:server", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,653 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Dukaan Saathi
3
+ emoji: 🛒
4
+ colorFrom: indigo
5
+ colorTo: green
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ tags:
11
+ - inventory
12
+ - kirana
13
+ - telugu
14
+ - fastapi
15
+ - minicpm-v
16
+ - modal
17
+ - speech-to-text
18
+ - sqlite
19
+ - human-in-the-loop
20
+ - small-business
21
+ - receipt-parsing
22
+ - react-agent
23
+ - approval-gated
24
+ - llm-finetuning
25
+ ---
26
+
27
+ # Dukaan Saathi · Small-Model Inventory Copilot for Kirana Stores
28
+
29
+ Dukaan Saathi is a phone-friendly inventory copilot for a small Indian convenience store.
30
+
31
+ The store owner uses Telugu/code-mixed commands during the day, sells products with English names, and receives messy supplier receipts on paper. The app helps turn those messy inputs into safe, reviewable inventory updates.
32
+
33
+ The goal is **not perfect OCR**. Supplier receipts can be noisy, handwritten, folded, and inconsistent. Dukaan Saathi uses a small vision model to create a draft, then lets the owner quickly correct it before anything touches inventory.
34
+
35
+ ## Core workflow
36
+
37
+ ```text
38
+ receipt photo / text command
39
+ → AI draft
40
+ → owner correction
41
+ → owner approval
42
+ → inventory update
43
+ → reorder suggestion
44
+ ```
45
+
46
+ Inventory is never updated directly from model output. Every write is approval-gated.
47
+
48
+ ## Using this Space
49
+
50
+ The app opens on the **Dashboard** tab. Navigate using the top menu:
51
+
52
+ - **Dashboard** — current stock levels, expiry status, and AI-generated insights
53
+ - **Inventory** — full product list; add or edit items
54
+ - **Bill Desk** — upload a supplier receipt photo or paste receipt text, correct extracted rows, then approve to update stock
55
+ - **Voice** — record or upload a stock command, transcribe it, then approve the proposed change
56
+ - **Orders** — pending reorder suggestions; mark as received after stock arrives
57
+ - **Analytics** — sales window (7 d / 30 d / 90 d)
58
+
59
+ **Note on state:** This Space uses SQLite with in-container storage. Inventory changes are visible during your session but may reset when the Space rebuilds. The seeded catalog (Bun, OBM, Happy Happy, Bingo (C), Parle (bulk)) is always restored on restart.
60
+
61
+ **Note on Modal services:** Receipt image OCR and speech transcription use Modal-hosted endpoints that may have a cold start of 10–30 seconds on first use. A warm-up call runs automatically on page load.
62
+
63
+ ## What it does
64
+
65
+ ### Stock commands
66
+
67
+ Example:
68
+
69
+ ```text
70
+ add Bun 12
71
+ ```
72
+
73
+ The app detects that 12 buns arrived and proposes an inventory update. The owner must approve before the stock value changes.
74
+
75
+ ### Receipt photo extraction
76
+
77
+ The owner uploads a supplier receipt photo. MiniCPM-V extracts likely product rows, quantities, and amounts into a review table.
78
+
79
+ The extraction can be imperfect. That is expected.
80
+
81
+ Example noisy draft:
82
+
83
+ ```text
84
+ 1. Port Ranges (c), qty 1, amount 2450
85
+ 2. Chocoly, qty 1, amount 8702
86
+ ```
87
+
88
+ ### Phone-friendly correction commands
89
+
90
+ Instead of forcing spreadsheet-style editing on a phone, the owner can type a simple correction:
91
+
92
+ ```text
93
+ first one Parle bulk, second one Bingo
94
+ ```
95
+
96
+ The owner can also record or upload correction audio. The app sends the audio to the Modal speech ASR endpoint, fills the correction command textbox with the transcript, and still waits for the owner to apply the correction and approve rows.
97
+
98
+ The app remaps the rows to known inventory products:
99
+
100
+ ```text
101
+ row 1 → Parle (bulk)
102
+ row 2 → Bingo (C)
103
+ ```
104
+
105
+ Matched rows become candidates for approval.
106
+
107
+ Supported correction examples:
108
+
109
+ ```text
110
+ first one Parle bulk
111
+ second one Bingo
112
+ row 1 Parle bulk
113
+ row 2 Bingo
114
+ skip row 2
115
+ quantity row 1 is 4
116
+ ```
117
+
118
+ ### Approval-gated inventory updates
119
+
120
+ The owner must explicitly approve stock commands and receipt rows before SQLite inventory is updated.
121
+
122
+ ### Reorder suggestions
123
+
124
+ When stock falls below threshold, the app drafts reorder suggestions grouped by supplier. Nothing is sent or purchased automatically.
125
+
126
+ ## Quick demo (text-only, no Modal needed)
127
+
128
+ Paste this into the **Bill Desk** receipt text box:
129
+
130
+ ```text
131
+ Mahalakshmi Marketing
132
+
133
+ | S.No | Particulars | Qty | Rate | Amount |
134
+ | 5/ | Port | 1 | X2450 | 2450 |
135
+ | 10/ | Rs.g/c | 4 | X8702 | 3480 |
136
+ ```
137
+
138
+ Click **Parse receipt text**, then type this correction:
139
+
140
+ ```text
141
+ first one Parle bulk, second one Bingo
142
+ ```
143
+
144
+ Click **Apply correction** → rows map to known products → click **Approve receipt rows** → inventory updates.
145
+
146
+ See the full [demo flow](#demo-flow) section below for the complete step-by-step walkthrough including voice and photo paths.
147
+
148
+ ## Why small models fit this problem
149
+
150
+ Small models are good enough to turn messy receipts and natural commands into useful drafts, but they should not be trusted to update business records directly.
151
+
152
+ Dukaan Saathi uses the model for interpretation and deterministic Python for safety-critical inventory logic:
153
+
154
+ ```text
155
+ MiniCPM-V output
156
+ → parsed candidate rows
157
+ → product matching
158
+ → owner correction
159
+ → owner approval
160
+ → SQLite write
161
+ ```
162
+
163
+ This keeps the workflow useful even when the model makes mistakes.
164
+
165
+ ## Model lifecycle
166
+
167
+ The receipt model is trained on Modal, then pushed to Hugging Face Hub for the
168
+ public Space runtime.
169
+
170
+ ```text
171
+ Modal synthetic data generation
172
+ → Modal LoRA fine-tuning
173
+ → LoRA adapter stored in a Modal Volume
174
+ → Modal push job merges adapter into the base model
175
+ → merged model pushed to Hugging Face Hub
176
+ → HF Space uses hf_inference to call that Hub model
177
+ → parsed receipt rows populate an editable table
178
+ → owner approval updates inventory
179
+ ```
180
+
181
+ Modal is the training and optional serving environment. Hugging Face Hub is the
182
+ public model artifact store. Hugging Face Inference is the public Space inference
183
+ path.
184
+
185
+ ### Fine-tuned receipt model
186
+
187
+ A LoRA adapter trained on Llama-3.2-3B-Instruct, stored in a Modal Volume:
188
+
189
+ ```text
190
+ Modal app: dukaan-saathi-receipt-llm
191
+ Modal Volume: dukaan-saathi-receipt-lora
192
+ Adapter path: /adapters/receipt-lora
193
+ Base model: unsloth/Llama-3.2-3B-Instruct-bnb-4bit
194
+ ```
195
+
196
+ After training, push the merged model to Hugging Face Hub:
197
+
198
+ ```bash
199
+ uv run modal run modal_apps/receipt_llm_service.py::push
200
+ ```
201
+
202
+ That push reads these values from `.env`:
203
+
204
+ ```text
205
+ HF_TOKEN=...
206
+ HF_RECEIPT_MODEL_REPO=summerdevlin46/dukaan-saathi-receipt-lora
207
+ ```
208
+
209
+ The public HF Space uses the pushed model through:
210
+
211
+ ```text
212
+ RECEIPT_BACKEND=hf_inference
213
+ HF_RECEIPT_MODEL_REPO=summerdevlin46/dukaan-saathi-receipt-lora
214
+ ```
215
+
216
+ The same adapter can also be served directly through a Modal receipt parser
217
+ endpoint for local or fallback runs. Deploying that endpoint writes this to
218
+ `.env`:
219
+
220
+ ```text
221
+ MODAL_RECEIPT_LLM_ENDPOINT=https://summerdevlin46--dukaan-saathi-receipt-llm-api.modal.run/parse
222
+ ```
223
+
224
+ This is not a local GGUF file. Local llama.cpp use is a separate optional path.
225
+
226
+ ### Training data
227
+
228
+ | File | Examples | Source |
229
+ |------|----------|--------|
230
+ | `data/finetune/receipt_examples.jsonl` | 6 | Hand-authored |
231
+ | `data/finetune/generated/receipt_examples_modal_synthetic.jsonl` | 22 | Modal LLM-generated |
232
+
233
+ To regenerate synthetic examples:
234
+
235
+ ```bash
236
+ scripts/modal_generate_receipt_examples.sh \
237
+ --count 48 \
238
+ --output data/finetune/generated/receipt_examples_modal_synthetic.jsonl
239
+ ```
240
+
241
+ To retrain the LoRA adapter on Modal:
242
+
243
+ ```bash
244
+ scripts/modal_finetune_receipt.sh --modal-synthetic-count 48 --max-steps 60 --epochs 8
245
+ ```
246
+
247
+ To redeploy the inference endpoint after retraining:
248
+
249
+ ```bash
250
+ scripts/modal_deploy.sh modal_apps/receipt_llm_service.py
251
+ ```
252
+
253
+ To update the public HF Space model after retraining, push again:
254
+
255
+ ```bash
256
+ uv run modal run modal_apps/receipt_llm_service.py::push
257
+ ```
258
+
259
+ ## Current stack
260
+
261
+ * **Gradio / Hugging Face Space** for the demo UI
262
+ * **Lean ReAct tool router** for selecting the small set of inventory/receipt tools
263
+ * **HF Inference API** for the public Hugging Face Space receipt parser path, using the model fine-tuned on Modal and pushed to HF Hub
264
+ * **llama.cpp + smolagents tools** for the local model-backed receipt parser path
265
+ * **MiniCPM-V 4.6** for receipt image extraction
266
+ * **Distil-Whisper small English** for correction-command speech transcription
267
+ * **Qwen2.5-1.5B-Instruct** for voice command NLU — semantic slot extraction (intent, product name, quantity, unit) from free-form and Telugu/English mixed commands
268
+ * **Modal** for hosting model endpoints
269
+ * **SQLite** for local inventory state
270
+ * **uv** for Python environment and commands
271
+ * **Deterministic Python services and fallback parsers** for:
272
+
273
+ * stock command parsing
274
+ * receipt text parsing
275
+ * receipt correction commands
276
+ * product matching
277
+ * inventory updates
278
+ * reorder drafts
279
+
280
+ Modal integrations are optional remote model services. The app-side Modal code
281
+ stays as thin HTTP clients; model serving code lives in `modal_apps/`.
282
+
283
+ For the public Hugging Face Space, use `RECEIPT_BACKEND=hf_inference` with
284
+ `HF_RECEIPT_MODEL_REPO` pointing at the published fine-tuned model. The
285
+ deterministic parser path exists for smoke tests, offline debugging, and safety
286
+ fallbacks; it is not the primary demo experience.
287
+
288
+ Modal can also host the receipt parser model. This is useful when local or
289
+ Hugging Face environments hit GPU/storage/runtime limits. With the current tiny
290
+ fine-tuning set, treat Modal fine-tuning as a demo-oriented adapter that improves
291
+ format following on known receipt styles, not as a generally reliable parser.
292
+
293
+ ## Runtime pipeline
294
+
295
+ The local orchestrator is:
296
+
297
+ ```bash
298
+ scripts/dev.sh
299
+ ```
300
+
301
+ It selects one of four staged runtime paths:
302
+
303
+ ```text
304
+ scripts/dev.sh --hf-inference
305
+ → scripts/run_app.sh --backend hf_inference
306
+ → uv run python app.py
307
+ → receipt text parsing calls the HF Inference API model in HF_RECEIPT_MODEL_REPO
308
+ ```
309
+
310
+ ```text
311
+ scripts/dev.sh --llamacpp
312
+ → scripts/start_llamacpp.sh
313
+ → uv run python scripts/download_models.py
314
+ → uv run python -m llama_cpp.server on port 8080
315
+ → uv run python -m llama_cpp.server on port 8082
316
+ → scripts/run_app.sh --backend llamacpp
317
+ → uv run python app.py
318
+ ```
319
+
320
+ ```text
321
+ scripts/dev.sh --modal-llm
322
+ → scripts/run_app.sh --backend modal_llm
323
+ ��� uv run python app.py
324
+ → receipt text parsing calls MODAL_RECEIPT_LLM_ENDPOINT
325
+ ```
326
+
327
+ ```text
328
+ scripts/dev.sh --deterministic
329
+ → scripts/run_app.sh --backend deterministic
330
+ → uv run python app.py
331
+ → receipt text parsing uses dukaan_saathi/parsers/receipt_text.py
332
+ ```
333
+
334
+ Receipt image and speech are separate optional Modal services:
335
+
336
+ ```text
337
+ receipt image
338
+ → ReAct router
339
+ → extract_text_from_receipt_image tool
340
+ → dukaan_saathi/integrations/modal_receipt.py
341
+ → MODAL_RECEIPT_ENDPOINT
342
+ → modal_apps/receipt_vlm_service.py
343
+ → raw receipt text
344
+ → parse_receipt_text_tool
345
+ → configured receipt parser backend
346
+ → editable receipt table
347
+ → owner approval
348
+ → dukaan_saathi/services/inventory.py
349
+ ```
350
+
351
+ ```text
352
+ voice or correction audio
353
+ → dukaan_saathi/integrations/speech.py
354
+ → MODAL_SPEECH_ENDPOINT
355
+ → transcript
356
+ → ReAct router for stock commands, or correction parser for receipt rows
357
+ → pending action / corrected editable rows
358
+ → owner approval
359
+ ```
360
+
361
+ Inventory writes only happen after approval:
362
+
363
+ ```text
364
+ approve command / approve receipt rows
365
+ → dukaan_saathi/services/inventory.py
366
+ → dukaan_saathi/storage.py
367
+ → SQLite stock ledger
368
+ ```
369
+
370
+ ## Agent status
371
+
372
+ The active Gradio path uses a lean ReAct-style router in
373
+ `dukaan_saathi/agent/react_agent.py`. It records `Thought`, `Action`, and
374
+ `Observation` trace lines, chooses the correct existing tool for the small task
375
+ set, and never writes inventory directly.
376
+
377
+ ReAct is the orchestrator, not the model. It calls tools; some tools call remote
378
+ models. For example, receipt-photo ReAct chooses the OCR tool, that tool calls
379
+ the Modal MiniCPM-V endpoint, then ReAct chooses the receipt parser tool, which
380
+ uses the configured backend:
381
+
382
+ ```text
383
+ Receipt photo
384
+ → ReAct
385
+ → Modal OCR tool
386
+ → receipt parser tool
387
+ → HF Inference / Modal LLM / llama.cpp / deterministic parser
388
+ → editable rows
389
+ → owner approval
390
+ → inventory write
391
+ ```
392
+
393
+ Voice follows the same approval-gated shape:
394
+
395
+ ```text
396
+ Audio
397
+ → Modal ASR
398
+ → transcript
399
+ → ReAct stock-command tool
400
+ → pending stock action
401
+ → owner approval
402
+ → inventory write
403
+ ```
404
+
405
+ This separation is intentional: Modal/HF/llama.cpp do expensive inference,
406
+ ReAct sequences safe tools and exposes a trace, and deterministic inventory
407
+ code performs approved writes.
408
+
409
+ The heavier `smolagents.ToolCallingAgent` implementation remains in
410
+ `dukaan_saathi/agent/agent.py`, but it is no longer the primary Gradio path. The
411
+ ReAct router calls the existing tool layer directly, which keeps the app simpler
412
+ while preserving the same approval gates.
413
+
414
+ ## Main files
415
+
416
+ ```text
417
+ app.py — FastAPI/Server entry point; routes dispatches and approval handlers
418
+ frontend_backend.py — adapter between custom HTML frontend and dukaan_saathi backend
419
+ dukaan_saathi/agent/react_agent.py — lean ReAct tool router; records Thought/Action/Observation traces
420
+ dukaan_saathi/agent/tools.py — parser, integration, and service tools called by the ReAct router
421
+ dukaan_saathi/parsers/stock_command.py — deterministic stock command parser (keyword + fuzzy catalog match)
422
+ dukaan_saathi/parsers/receipt_text.py — deterministic receipt text parser
423
+ dukaan_saathi/parsers/receipt_correction.py — row correction command parser
424
+ dukaan_saathi/services/inventory.py — canonical inventory write boundary (all stock writes go here)
425
+ dukaan_saathi/services/reorder.py — reorder suggestion generator
426
+ dukaan_saathi/storage.py — SQLite access, seed data, find_product
427
+ dukaan_saathi/integrations/command_nlu.py — Qwen2.5-1.5B NLU HTTP client (MODAL_NLU_ENDPOINT)
428
+ dukaan_saathi/integrations/modal_receipt.py — MiniCPM-V OCR HTTP client (MODAL_RECEIPT_ENDPOINT)
429
+ dukaan_saathi/integrations/speech.py — Distil-Whisper ASR HTTP client (MODAL_SPEECH_ENDPOINT)
430
+ modal_apps/command_nlu_service.py — Qwen2.5-1.5B slot extraction endpoint
431
+ modal_apps/receipt_vlm_service.py — MiniCPM-V receipt OCR endpoint
432
+ modal_apps/speech_asr_service.py — Distil-Whisper ASR endpoint
433
+ modal_apps/receipt_llm_service.py — receipt LLM train/serve/push
434
+ modal_apps/receipt_data_generator.py — synthetic receipt example generator
435
+ scripts/dev.sh — local run entrypoint
436
+ scripts/modal_deploy.sh — deploy a Modal service and write its URL to .env
437
+ smoke_tests/test_custom_app_safety.py — approval gate, NLU, and Modal integration tests
438
+ smoke_tests/test_receipt_parser_regression.py
439
+ smoke_tests/test_receipt_correction.py
440
+ ```
441
+
442
+ ## Run locally
443
+
444
+ ### Prerequisites
445
+
446
+ - Python 3.13+
447
+ - [uv](https://docs.astral.sh/uv/getting-started/installation/) (`pip install uv` or `curl -LsSf https://astral.sh/uv/install.sh | sh`)
448
+
449
+ ### 1 — Install dependencies
450
+
451
+ ```bash
452
+ uv sync
453
+ ```
454
+
455
+ ### 2 — Configure environment
456
+
457
+ ```bash
458
+ cp .env.example .env
459
+ ```
460
+
461
+ Then edit `.env`. The minimum required value depends on which backend you run:
462
+
463
+ | Backend | Required in `.env` |
464
+ |---------|-------------------|
465
+ | `hf_inference` (recommended) | `HF_RECEIPT_MODEL_REPO=summerdevlin46/dukaan-saathi-receipt-lora` |
466
+ | `modal_llm` | `MODAL_RECEIPT_LLM_ENDPOINT=<url from modal_deploy.sh>` |
467
+ | `deterministic` | nothing — no model calls |
468
+ | `llamacpp` | nothing extra — models downloaded automatically |
469
+
470
+ Optional Modal services (add when you have them; app runs without them):
471
+
472
+ ```text
473
+ MODAL_RECEIPT_ENDPOINT=... # receipt image OCR (MiniCPM-V)
474
+ MODAL_SPEECH_ENDPOINT=... # speech transcription (Distil-Whisper)
475
+ MODAL_NLU_ENDPOINT=... # voice command slot extraction (Qwen2.5-1.5B)
476
+ ```
477
+
478
+ `HF_TOKEN` is only needed if `HF_RECEIPT_MODEL_REPO` is a private repo.
479
+
480
+ **Running on the public HF Space?** Modal endpoints must be added as Space secrets in the HF UI — see [docs/deployment_setup.md](docs/deployment_setup.md) for the full walkthrough.
481
+
482
+ ### 3 — Run
483
+
484
+ Pick one backend and start the app. It opens at **http://127.0.0.1:7860**.
485
+
486
+ **HF Inference (recommended for full demo)**
487
+
488
+ Calls the fine-tuned receipt model hosted on Hugging Face Hub. Requires
489
+ `HF_RECEIPT_MODEL_REPO` in `.env`.
490
+
491
+ ```bash
492
+ scripts/dev.sh --hf-inference
493
+ ```
494
+
495
+ **Deterministic (fastest, no model needed)**
496
+
497
+ Uses rule-based parsers only. Stock commands, receipt text, corrections, and
498
+ approval all work. Use this to verify UI and approval flows without any model
499
+ calls.
500
+
501
+ ```bash
502
+ scripts/dev.sh --deterministic
503
+ ```
504
+
505
+ **Modal LLM (fine-tuned model served on Modal)**
506
+
507
+ Calls the LoRA-fine-tuned endpoint you deployed on Modal. Requires
508
+ `MODAL_RECEIPT_LLM_ENDPOINT` in `.env`.
509
+
510
+ ```bash
511
+ scripts/dev.sh --modal-llm
512
+ ```
513
+
514
+ **Local llama.cpp (fully offline fallback)**
515
+
516
+ Downloads GGUF models and starts two llama.cpp servers on ports 8080 and 8082,
517
+ then starts the app. Slow first start; receipt quality depends on whether a
518
+ fine-tuned GGUF is available via `HF_RECEIPT_GGUF_REPO`.
519
+
520
+ ```bash
521
+ scripts/dev.sh --llamacpp
522
+ ```
523
+
524
+ ### 4 — Run tests
525
+
526
+ ```bash
527
+ uv run scripts/smoke_test.sh
528
+ ```
529
+
530
+ Focused test runs:
531
+
532
+ ```bash
533
+ uv run python -m pytest smoke_tests/test_custom_app_safety.py -v
534
+ uv run python -m pytest smoke_tests/test_receipt_parser_regression.py smoke_tests/test_receipt_correction.py -q
535
+ ```
536
+
537
+ ## Modal endpoints
538
+
539
+ Deploy the MiniCPM-V receipt endpoint:
540
+
541
+ ```bash
542
+ scripts/modal_deploy.sh modal_apps/receipt_vlm_service.py
543
+ ```
544
+
545
+ Deploy the speech ASR endpoint:
546
+
547
+ ```bash
548
+ scripts/modal_deploy.sh modal_apps/speech_asr_service.py
549
+ ```
550
+
551
+ Deploy the voice command NLU endpoint:
552
+
553
+ ```bash
554
+ scripts/modal_deploy.sh modal_apps/command_nlu_service.py
555
+ ```
556
+
557
+ All three commands deploy the Modal app and write the generated endpoint URL to `.env`.
558
+ `MODAL_RECEIPT_ENDPOINT`, `MODAL_SPEECH_ENDPOINT`, and `MODAL_NLU_ENDPOINT` are written automatically.
559
+
560
+ Load the endpoint environment:
561
+
562
+ ```bash
563
+ source scripts/_env.sh
564
+ ```
565
+
566
+ Health check:
567
+
568
+ ```bash
569
+ BASE_URL="${MODAL_RECEIPT_ENDPOINT%/extract}"
570
+ curl "$BASE_URL/health"
571
+ ```
572
+
573
+ Speech health check:
574
+
575
+ ```bash
576
+ SPEECH_HEALTH_URL="${MODAL_SPEECH_ENDPOINT/speech-transcribe/speech-health}"
577
+ curl "$SPEECH_HEALTH_URL"
578
+ ```
579
+
580
+ Test receipt extraction directly (replace with your own receipt image):
581
+
582
+ ```bash
583
+ curl -sS -X POST "$MODAL_RECEIPT_ENDPOINT" \
584
+ -F "image=@/path/to/receipt.jpeg"
585
+ ```
586
+
587
+ Test speech transcription directly:
588
+
589
+ ```bash
590
+ curl -sS -X POST "$MODAL_SPEECH_ENDPOINT" \
591
+ -F "audio=@path/to/audio.wav"
592
+ ```
593
+
594
+ NLU health check:
595
+
596
+ ```bash
597
+ curl "${MODAL_NLU_ENDPOINT}" \
598
+ -X POST -H "Content-Type: application/json" \
599
+ -d '{"command": "add Bun 12"}'
600
+ ```
601
+
602
+ Stop Modal to save cost:
603
+
604
+ ```bash
605
+ uv run modal app stop dukaan-saathi-receipt-vlm || true
606
+ uv run modal app stop dukaan-saathi-speech-asr || true
607
+ uv run modal app stop dukaan-saathi-command-nlu || true
608
+ uv run modal app list
609
+ ```
610
+
611
+ Look for:
612
+
613
+ ```text
614
+ Tasks 0
615
+ ```
616
+
617
+ ## Demo flow
618
+
619
+ Full walkthrough covering stock commands, receipt photo, and voice correction:
620
+
621
+ ```text
622
+ 1. Open Dukaan Saathi.
623
+ 2. Show current inventory.
624
+ 3. Enter: add Bun 12
625
+ 4. Click Parse command.
626
+ 5. Approve the proposed stock update.
627
+ 6. Show the updated inventory and reorder draft.
628
+ 7. Upload a supplier receipt photo.
629
+ 8. MiniCPM-V extracts imperfect rows.
630
+ 9. Type or record this correction: first one Parle bulk, second one Bingo
631
+ 10. If using audio, click Transcribe correction audio.
632
+ 11. Click Apply correction.
633
+ 12. Show rows mapped to known inventory products.
634
+ 13. Click Approve receipt rows.
635
+ 14. Show inventory updated.
636
+ 15. Show reorder draft updated.
637
+ ```
638
+
639
+ ## Safety rule
640
+
641
+ Model output never writes inventory directly.
642
+
643
+ The app always follows this flow:
644
+
645
+ ```text
646
+ model output
647
+ → parsed draft
648
+ → owner review/correction
649
+ → owner approval
650
+ → inventory write
651
+ ```
652
+
653
+ This is the core design principle of Dukaan Saathi.
app.py ADDED
@@ -0,0 +1,706 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Kirana AI × Dukaan Saathi — gr.Server with hand-rolled HTML frontend.
3
+
4
+ UI shell ported from kirana-ai; storage and parsers come from dukaan_saathi.
5
+ """
6
+
7
+ import json
8
+ import shutil
9
+ import tempfile
10
+ import threading
11
+ from pathlib import Path
12
+
13
+ from gradio import Server
14
+ from fastapi import File, Form, UploadFile
15
+ from fastapi.responses import HTMLResponse
16
+ from fastapi.staticfiles import StaticFiles
17
+
18
+ import kirana_db as db
19
+ import ui as ui_render
20
+ from frontend_backend import run_analysis, run_command_parse
21
+ from dukaan_saathi import config
22
+ from dukaan_saathi.agent.react_agent import get_react_agent
23
+ from dukaan_saathi.integrations.modal_receipt import _extract_receipt_result_with_modal
24
+ from dukaan_saathi.integrations.speech import transcribe_audio
25
+ from dukaan_saathi.parsers.receipt_text import parse_receipt_text
26
+ from dukaan_saathi.traceability import new_run_id, utc_now_iso, write_manifest
27
+ from dukaan_saathi.integrations.hub_traces import push_trace
28
+
29
+ db.init_db()
30
+
31
+ STATIC_DIR = Path(__file__).parent / "static"
32
+
33
+
34
+ INITIAL_STATE = {
35
+ "page": "dashboard",
36
+ "filters": {"q": "", "category": "All", "status": "All"},
37
+ "analytics_days": 30,
38
+ "orders_filter": "pending",
39
+ "active_method": "manual",
40
+ "photo_result": None,
41
+ "voice_result": None,
42
+ "insights": {"inventory": "", "seasonal": "", "expiry": ""},
43
+ }
44
+
45
+
46
+ def _new_state() -> dict:
47
+ return json.loads(json.dumps(INITIAL_STATE))
48
+
49
+
50
+ def _parse_receipt_with_configured_backend(raw_text: str):
51
+ """Use the production receipt parser backend for OCR/plain text."""
52
+ if config.RECEIPT_BACKEND == "hf_inference":
53
+ from dukaan_saathi.integrations.hf_inference_receipt import parse_receipt_via_hf_inference
54
+ return parse_receipt_via_hf_inference(raw_text)
55
+
56
+ if config.RECEIPT_BACKEND == "modal_llm":
57
+ from dukaan_saathi.integrations.modal_receipt_llm import parse_receipt_with_modal_llm
58
+ return parse_receipt_with_modal_llm(raw_text)
59
+
60
+ if config.RECEIPT_BACKEND == "llamacpp":
61
+ from dukaan_saathi.integrations.llamacpp_receipt import parse_receipt_via_llm
62
+ return parse_receipt_via_llm(raw_text)
63
+
64
+ return parse_receipt_text(raw_text)
65
+
66
+
67
+ def _match_receipt_rows(rows: list[dict], trace: list[str] | None = None) -> list[dict]:
68
+ matched_rows = []
69
+ trace = trace if trace is not None else []
70
+ for row in rows or []:
71
+ next_row = dict(row)
72
+ if next_row.get("matched_product_id"):
73
+ matched_rows.append(next_row)
74
+ continue
75
+
76
+ product_raw = (next_row.get("product_raw") or "").strip()
77
+ if not product_raw:
78
+ matched_rows.append(next_row)
79
+ continue
80
+
81
+ matches = db.find_by_name(product_raw)
82
+ if matches:
83
+ match = matches[0]
84
+ next_row["matched_product_id"] = match["id"]
85
+ next_row["matched_product_name"] = match["name"]
86
+ trace.append(f"[receipt_match] Matched '{product_raw}' to {match['name']}")
87
+ else:
88
+ trace.append(f"[receipt_match] No catalog match for '{product_raw}'")
89
+ matched_rows.append(next_row)
90
+ return matched_rows
91
+
92
+
93
+ def _find_order(order_id: str) -> dict | None:
94
+ for order in db.get_all_orders(limit=500):
95
+ if str(order.get("id")) == str(order_id):
96
+ return order
97
+ return None
98
+
99
+
100
+ def _extract_receipt_direct(tmp_path: str) -> tuple[list[dict], list[str], str, str]:
101
+ ocr_result = _extract_receipt_result_with_modal(tmp_path)
102
+ trace = list(getattr(ocr_result, "trace", []) or [])
103
+ raw_text = getattr(ocr_result, "raw_text", "") or ""
104
+ model = getattr(ocr_result, "model", "unknown")
105
+ if not trace:
106
+ trace = [
107
+ f"[receipt_ocr] OCR model: {model}",
108
+ f"[receipt_ocr] Raw text length: {len(raw_text)}",
109
+ ]
110
+
111
+ if not raw_text.strip():
112
+ return [], trace, raw_text, model
113
+
114
+ rows, parse_trace, raw_text, _ = _parse_receipt_rows_from_text(raw_text)
115
+ trace.extend(parse_trace)
116
+ return rows, trace, raw_text, model
117
+
118
+
119
+ def _parse_receipt_rows_from_text(
120
+ raw_text: str,
121
+ backend_parser=_parse_receipt_with_configured_backend,
122
+ ) -> tuple[list[dict], list[str], str, str]:
123
+ trace: list[str] = []
124
+ try:
125
+ rows, parser_trace = backend_parser(raw_text)
126
+ trace.extend(parser_trace)
127
+ except Exception as exc:
128
+ trace.append(f"[receipt_parser] Configured backend failed: {exc}")
129
+ trace.append("[receipt_parser] Falling back to deterministic parser.")
130
+ rows, parser_trace = parse_receipt_text(raw_text)
131
+ trace.extend(parser_trace)
132
+ rows = _match_receipt_rows(rows, trace)
133
+ return rows, trace, raw_text, "text"
134
+
135
+
136
+ def _service_status() -> dict:
137
+ import os
138
+
139
+ return {
140
+ "receipt_backend": config.RECEIPT_BACKEND,
141
+ "hf_receipt_model": bool(os.getenv("HF_RECEIPT_MODEL_REPO", "").strip()),
142
+ "modal_ocr": bool(
143
+ (os.getenv("MODAL_RECEIPT_ENDPOINT") or os.getenv("MINICPM_RECEIPT_ENDPOINT") or "").strip()
144
+ ),
145
+ "modal_speech": bool(
146
+ (os.getenv("MODAL_SPEECH_ENDPOINT") or os.getenv("SPEECH_ASR_ENDPOINT") or "").strip()
147
+ ),
148
+ }
149
+
150
+
151
+ # ──────────────────────────────────────────────────────────────────────────────
152
+ # Traceability helper
153
+ # ──────────────────────────────────────────────────────────────────────────────
154
+
155
+ def _record_approval_manifest(source_doc: str, result: dict) -> None:
156
+ """Write a lightweight approval manifest for FastAPI-path stock writes."""
157
+ ts = utc_now_iso()
158
+ try:
159
+ write_manifest({
160
+ "run_id": new_run_id("inventory-approval"),
161
+ "kind": "inventory-approval",
162
+ "status": "succeeded",
163
+ "started_at": ts,
164
+ "ended_at": ts,
165
+ "metadata": {
166
+ "approval_type": source_doc,
167
+ "product_id": result.get("product_id", ""),
168
+ "product_name": result.get("product_name", ""),
169
+ "previous_stock": result.get("previous_stock"),
170
+ "new_stock": result.get("new_stock"),
171
+ "delta": result.get("delta"),
172
+ },
173
+ })
174
+ except Exception:
175
+ pass # manifest writes must never break the approval flow
176
+
177
+
178
+ # ──────────────────────────────────────────────────────────────────────────────
179
+ # Action handlers — each returns (state, toast)
180
+ # ──────────────────────────────────────────────────────────────────────────────
181
+
182
+ def _h_navigate(state, params):
183
+ state["page"] = params.get("to", "dashboard")
184
+ return state, ""
185
+
186
+
187
+ def _h_refresh(state, _params):
188
+ return state, "success|Refreshed"
189
+
190
+
191
+ def _h_run_analysis(state, _params):
192
+ result = run_analysis()
193
+ state["insights"] = {
194
+ "inventory": result.get("ai_inventory_analysis", ""),
195
+ "seasonal": result.get("ai_seasonal_advice", ""),
196
+ "expiry": result.get("ai_expiry_advice", ""),
197
+ }
198
+ ui_render.invalidate_insights()
199
+ n = len(result.get("suggested_orders", []))
200
+ state["page"] = "dashboard"
201
+ msg = f"AI analysis complete · {n} restock order(s) generated" if n else "AI analysis complete"
202
+ return state, f"success|{msg}"
203
+
204
+
205
+ def _h_refresh_insights(state, _params):
206
+ ui_render.invalidate_insights()
207
+ state["page"] = "dashboard"
208
+ return state, "success|Refreshing AI insights…"
209
+
210
+
211
+ def _h_add_to_order(state, params):
212
+ pid = params.get("pid")
213
+ try:
214
+ qty = float(params.get("qty"))
215
+ except (TypeError, ValueError):
216
+ return state, "danger|Could not queue this reorder"
217
+ p = db.get_product(pid)
218
+ if not p:
219
+ state["page"] = "dashboard"
220
+ return state, "danger|Product not found"
221
+ db.insert_orders([{
222
+ "product_id": pid,
223
+ "product_name": p["name"],
224
+ "qty_needed": qty,
225
+ "unit": p["unit"],
226
+ "reason": "Manual reorder from dashboard",
227
+ "ai_confidence": 0.95,
228
+ }])
229
+ state["page"] = "orders"
230
+ state["orders_filter"] = "pending"
231
+ return state, f"success|Reorder queued for {p['name']}"
232
+
233
+
234
+ def _h_offer_to_route(state, params):
235
+ pid = params.get("pid")
236
+ p = db.get_product(pid)
237
+ if not p:
238
+ state["page"] = "dashboard"
239
+ return state, "danger|Product not found"
240
+ db.insert_orders([{
241
+ "product_id": pid,
242
+ "product_name": p["name"],
243
+ "qty_needed": p["quantity"],
244
+ "unit": p["unit"],
245
+ "reason": "Liquidation route offer for near-expiry or overstock item",
246
+ "ai_confidence": 0.7,
247
+ }])
248
+ state["page"] = "orders"
249
+ state["orders_filter"] = "pending"
250
+ return state, f"success|Liquidation offer logged for {p['name']}"
251
+
252
+
253
+ def _h_plan_festival_stock(state, params):
254
+ key = (params.get("key") or "").strip()
255
+ state["page"] = "seasonal"
256
+ return state, f"info|Festival plan opened · {key or 'upcoming'}"
257
+
258
+
259
+ def _h_filter_inventory(state, params):
260
+ state["filters"]["q"] = params.get("q", "")
261
+ state["filters"]["category"] = params.get("category", "All")
262
+ state["filters"]["status"] = params.get("status", "All")
263
+ state["page"] = "inventory"
264
+ return state, ""
265
+
266
+
267
+ def _h_update_stock(state, params):
268
+ pid = params.get("pid")
269
+ try:
270
+ qty = float(params["qty"])
271
+ except (KeyError, ValueError, TypeError):
272
+ return state, "danger|Invalid product ID or quantity"
273
+ mode = params.get("mode", "add")
274
+ db.adjust_stock(pid, qty, mode=mode)
275
+ p = db.get_product(pid)
276
+ state["page"] = "inventory"
277
+ return state, ("success|" + (f"{p['name']} → {p['quantity']} {p['unit']}" if p else "Updated"))
278
+
279
+
280
+ def _h_record_sale(state, params):
281
+ pid = params.get("pid")
282
+ try:
283
+ qty = float(params["qty"]); price = float(params["price"])
284
+ except (KeyError, ValueError, TypeError):
285
+ return state, "danger|Invalid sale input"
286
+ db.record_sale(pid, qty, price)
287
+ p = db.get_product(pid)
288
+ state["page"] = "inventory"
289
+ return state, "success|" + (f"Sale recorded · {p['name']} remaining {p['quantity']}" if p else "Sale recorded")
290
+
291
+
292
+ def _h_delete_product(state, params):
293
+ pid = params.get("pid")
294
+ if not pid:
295
+ return state, "danger|Invalid ID"
296
+ p = db.get_product(pid)
297
+ if not p:
298
+ return state, "warn|Product not found"
299
+ db.delete_product(pid)
300
+ state["page"] = "inventory"
301
+ return state, f"success|'{p['name']}' deleted"
302
+
303
+
304
+ def _h_add_product(state, params):
305
+ name = (params.get("name") or "").strip()
306
+ if not name:
307
+ return state, "danger|Product name is required"
308
+ try:
309
+ qty = float(params.get("qty") or 0)
310
+ min_stock = float(params.get("min_stock") or 0)
311
+ buy = float(params.get("buy_price") or 0)
312
+ sell = float(params.get("sell_price") or 0)
313
+ except (ValueError, TypeError):
314
+ return state, "danger|Quantity and prices must be numbers"
315
+ expiry = (params.get("expiry_date") or "").strip() or None
316
+ db.add_product(
317
+ name, params.get("category", "Other"), qty, params.get("unit", "kg"),
318
+ min_stock, buy, sell,
319
+ name_local=(params.get("name_local") or "").strip(),
320
+ expiry_date=expiry,
321
+ supplier=(params.get("supplier") or "").strip(),
322
+ )
323
+ state["page"] = "inventory"
324
+ state["filters"] = {"q": "", "category": "All", "status": "All"}
325
+ return state, f"success|'{name}' added to inventory"
326
+
327
+
328
+ def _h_apply_receipt_row(state, params):
329
+ qty = params.get("quantity") or 0
330
+ try:
331
+ qty_f = float(qty)
332
+ except (ValueError, TypeError):
333
+ qty_f = 0.0
334
+ if qty_f <= 0:
335
+ state["page"] = "add"; state["active_method"] = "photo"
336
+ return state, "danger|Row has no usable quantity"
337
+
338
+ pid = params.get("matched_product_id")
339
+ if pid:
340
+ result = db.adjust_stock(pid, qty_f, mode="add")
341
+ _record_approval_manifest("receipt_row", result)
342
+ p = db.get_product(pid)
343
+ name = p["name"] if p else pid
344
+ msg = f"Added {qty_f:g} to {name}"
345
+ else:
346
+ name = (params.get("product_raw") or "").strip() or "Unknown item"
347
+ unit_price = float(params.get("unit_price") or 0)
348
+ db.add_product(
349
+ name, "Other", qty_f, "unit",
350
+ min_stock=0, buy_price=unit_price, sell_price=0,
351
+ supplier=(params.get("supplier") or "").strip(),
352
+ )
353
+ msg = f"Created '{name}' with {qty_f:g} units"
354
+
355
+ photo_result = state.get("photo_result") or {}
356
+ push_trace(
357
+ input_type="photo",
358
+ raw_command=name,
359
+ trace=photo_result.get("trace") or [],
360
+ action="add_stock",
361
+ product=name,
362
+ quantity=qty_f,
363
+ )
364
+ state["page"] = "add"; state["active_method"] = "photo"
365
+ return state, f"success|{msg}"
366
+
367
+
368
+ def _h_voice_command(state, params):
369
+ text = (params.get("text") or "").strip()
370
+ if not text:
371
+ return state, "warn|Please type a command"
372
+ parsed = run_command_parse(text)
373
+ action = parsed.get("action", "unknown")
374
+ pid = parsed.get("product_id")
375
+ qty = parsed.get("quantity")
376
+ needs_approval = action in {"add_stock", "set_stock"} and bool(pid) and qty is not None
377
+
378
+ state["voice_result"] = {
379
+ "action": action,
380
+ "product": parsed.get("product", ""),
381
+ "product_id": pid,
382
+ "quantity": qty,
383
+ "unit": parsed.get("unit", ""),
384
+ "confidence": parsed.get("confidence", "low"),
385
+ "trace": parsed.get("trace", []),
386
+ "applied": None,
387
+ "needs_approval": needs_approval,
388
+ "suggested_name": parsed.get("suggested_name"),
389
+ "suggested_qty": parsed.get("suggested_qty"),
390
+ "raw_command": text,
391
+ }
392
+ state["page"] = "add"
393
+ state["active_method"] = "voice"
394
+ if needs_approval:
395
+ return state, "info|Command parsed — approve before stock changes"
396
+ return state, "warn|Could not parse a stock update"
397
+
398
+
399
+ def _h_voice_apply(state, params):
400
+ action = params.get("action")
401
+ pid = params.get("product_id")
402
+ qty = params.get("quantity")
403
+ if action not in {"add_stock", "set_stock"} or not pid or qty is None:
404
+ state["page"] = "add"
405
+ state["active_method"] = "voice"
406
+ return state, "danger|No valid parsed command to apply"
407
+
408
+ try:
409
+ qty_f = float(qty)
410
+ except (TypeError, ValueError):
411
+ state["page"] = "add"
412
+ state["active_method"] = "voice"
413
+ return state, "danger|Invalid quantity"
414
+
415
+ mode = "add" if action == "add_stock" else "set"
416
+ result = db.adjust_stock(pid, qty_f, mode=mode)
417
+ _record_approval_manifest("voice_command", result)
418
+ p = db.get_product(pid)
419
+ name = p["name"] if p else params.get("product", "product")
420
+ applied = f"Added {qty_f:g} to {name}" if mode == "add" else f"Set {name} stock to {qty_f:g}"
421
+ push_trace(
422
+ input_type="voice",
423
+ raw_command=params.get("raw_command") or params.get("product", ""),
424
+ trace=params.get("trace") or [],
425
+ action=action,
426
+ product=name,
427
+ quantity=qty_f,
428
+ )
429
+
430
+ state["voice_result"] = {
431
+ "action": action,
432
+ "product": name,
433
+ "product_id": pid,
434
+ "quantity": qty_f,
435
+ "unit": params.get("unit", ""),
436
+ "confidence": params.get("confidence", "high"),
437
+ "trace": params.get("trace", []),
438
+ "applied": applied,
439
+ "needs_approval": False,
440
+ }
441
+ state["page"] = "inventory"
442
+ state["filters"] = {"q": "", "category": "All", "status": "All"}
443
+ return state, f"success|{applied}"
444
+
445
+
446
+ def _h_generate_orders(state, _params):
447
+ result = run_analysis()
448
+ n = len(result.get("suggested_orders", []))
449
+ state["page"] = "orders"
450
+ state["orders_filter"] = "pending"
451
+ return state, ("success|" + (f"{n} order(s) generated" if n else "No restock needed"))
452
+
453
+
454
+ def _h_filter_orders(state, params):
455
+ state["orders_filter"] = params.get("status", "pending")
456
+ state["page"] = "orders"
457
+ return state, ""
458
+
459
+
460
+ def _h_filter_analytics(state, params):
461
+ try:
462
+ days = int(params.get("days", 30))
463
+ except (TypeError, ValueError):
464
+ days = 30
465
+ state["analytics_days"] = days if days in {7, 30, 90} else 30
466
+ state["page"] = "analytics"
467
+ return state, ""
468
+
469
+
470
+ def _h_approve_order(state, params):
471
+ oid = params.get("oid")
472
+ if not oid:
473
+ return state, "danger|Invalid order ID"
474
+ db.update_order_status(oid, "approved")
475
+ state["page"] = "orders"
476
+ return state, f"success|Order #{oid} approved"
477
+
478
+
479
+ def _h_mark_order_received(state, params):
480
+ oid = params.get("oid")
481
+ if not oid:
482
+ return state, "danger|Invalid order ID"
483
+ order = _find_order(str(oid))
484
+ if not order:
485
+ state["page"] = "orders"
486
+ return state, "danger|Order not found"
487
+ if order.get("status") != "approved":
488
+ state["page"] = "orders"
489
+ return state, "warn|Approve the order before marking it received"
490
+ if not order.get("product_id"):
491
+ state["page"] = "orders"
492
+ return state, "danger|Order has no product match"
493
+
494
+ db.adjust_stock(order["product_id"], float(order.get("qty_needed") or 0), mode="add")
495
+ db.update_order_status(oid, "received")
496
+ state["page"] = "orders"
497
+ state["orders_filter"] = "received"
498
+ return state, f"success|Order #{oid} received and stock updated"
499
+
500
+
501
+ def _h_reject_order(state, params):
502
+ oid = params.get("oid")
503
+ if not oid:
504
+ return state, "danger|Invalid order ID"
505
+ db.update_order_status(oid, "rejected")
506
+ state["page"] = "orders"
507
+ return state, f"warn|Order #{oid} rejected"
508
+
509
+
510
+ def _h_save_settings(state, params):
511
+ for key in ("shop_name", "owner_name", "region", "low_stock_days_ahead", "expiry_warn_days"):
512
+ if key in params:
513
+ db.set_setting(key, str(params[key]))
514
+ state["page"] = "settings"
515
+ return state, "success|Settings saved"
516
+
517
+
518
+ HANDLERS = {
519
+ "navigate": _h_navigate,
520
+ "refresh": _h_refresh,
521
+ "run_analysis": _h_run_analysis,
522
+ "refresh_insights": _h_refresh_insights,
523
+ "add_to_order": _h_add_to_order,
524
+ "offer_to_route": _h_offer_to_route,
525
+ "plan_festival_stock": _h_plan_festival_stock,
526
+ "filter_inventory": _h_filter_inventory,
527
+ "update_stock": _h_update_stock,
528
+ "record_sale": _h_record_sale,
529
+ "delete_product": _h_delete_product,
530
+ "add_product": _h_add_product,
531
+ "apply_receipt_row": _h_apply_receipt_row,
532
+ "voice_command": _h_voice_command,
533
+ "voice_apply": _h_voice_apply,
534
+ "generate_orders": _h_generate_orders,
535
+ "filter_orders": _h_filter_orders,
536
+ "filter_analytics": _h_filter_analytics,
537
+ "approve_order": _h_approve_order,
538
+ "mark_order_received": _h_mark_order_received,
539
+ "reject_order": _h_reject_order,
540
+ "save_settings": _h_save_settings,
541
+ }
542
+
543
+
544
+ # ──────────────────────────────────────────────────────────────────────────────
545
+ # gr.Server engine
546
+ # ──────────────────────────────────────────────────────────────────────────────
547
+ server = Server(title="Kirana AI", docs_url=None, redoc_url=None)
548
+ server.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
549
+
550
+
551
+ INDEX_HTML = """<!doctype html>
552
+ <html lang="en" data-theme="dark">
553
+ <head>
554
+ <meta charset="utf-8">
555
+ <meta name="viewport" content="width=device-width, initial-scale=1">
556
+ <title>Kirana AI</title>
557
+ <link rel="preconnect" href="https://fonts.googleapis.com">
558
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
559
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
560
+ <link rel="stylesheet" href="/static/app.css">
561
+ </head>
562
+ <body>
563
+ <div class="page-host">{initial_html}</div>
564
+ <input type="file" id="kirana-photo-input" accept="image/*" style="position:absolute;left:-9999px;top:-9999px;">
565
+ <script>window.__KIRANA_STATE__ = {initial_state_json};</script>
566
+ <script src="/static/app.js" defer></script>
567
+ </body>
568
+ </html>"""
569
+
570
+
571
+ @server.get("/", response_class=HTMLResponse)
572
+ def index() -> str:
573
+ state = _new_state()
574
+ html = ui_render.render("dashboard", state)
575
+ return INDEX_HTML.format(
576
+ initial_html=html,
577
+ initial_state_json=json.dumps(state),
578
+ )
579
+
580
+
581
+ @server.post("/api/dispatch")
582
+ def api_dispatch(payload: dict) -> dict:
583
+ state = payload.get("state") or _new_state()
584
+ action = payload.get("action", "")
585
+ params = payload.get("params") or {}
586
+
587
+ handler = HANDLERS.get(action)
588
+ if not handler:
589
+ html = ui_render.render(state.get("page", "dashboard"), state,
590
+ toast=f"warn|Unknown action: {action}")
591
+ return {"html": html, "state": state}
592
+
593
+ state, toast = handler(state, params)
594
+ html = ui_render.render(state["page"], state, toast=toast)
595
+ return {"html": html, "state": state}
596
+
597
+
598
+ @server.post("/api/photo")
599
+ async def api_photo(state: str = Form(...), image: UploadFile = File(...)) -> dict:
600
+ state_dict = json.loads(state) if state else _new_state()
601
+ suffix = Path(image.filename or "").suffix or ".jpg"
602
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
603
+ shutil.copyfileobj(image.file, tmp)
604
+ tmp_path = tmp.name
605
+
606
+ try:
607
+ react_result = get_react_agent().extract_receipt_image(tmp_path)
608
+ trace = list(react_result.trace)
609
+ rows = _match_receipt_rows(react_result.receipt_rows or [], trace)
610
+ raw_text = react_result.raw_text or ""
611
+ ocr_model = "react_agent"
612
+ except Exception as exc:
613
+ rows, trace, raw_text, ocr_model = _extract_receipt_direct(tmp_path)
614
+ trace.insert(0, f"[react_agent] Unavailable; used direct receipt path: {exc}")
615
+
616
+ if rows:
617
+ result = {
618
+ "rows": rows,
619
+ "trace": trace,
620
+ "raw_text": raw_text,
621
+ "ocr_model": ocr_model,
622
+ }
623
+ toast = f"info|Receipt parsed · {len(rows)} row(s)"
624
+ else:
625
+ result = {
626
+ "error": trace[-1] if trace else "No rows extracted",
627
+ "trace": trace,
628
+ "raw_text": raw_text,
629
+ "ocr_model": ocr_model,
630
+ }
631
+ toast = f"warn|{result['error']}"
632
+
633
+ state_dict["photo_result"] = result
634
+ state_dict["page"] = "add"
635
+ state_dict["active_method"] = "photo"
636
+ html = ui_render.render(state_dict["page"], state_dict, toast=toast)
637
+ return {"html": html, "state": state_dict}
638
+
639
+
640
+ @server.post("/api/speech")
641
+ async def api_speech(state: str = Form(...), audio: UploadFile = File(...)) -> dict:
642
+ state_dict = json.loads(state) if state else _new_state()
643
+ suffix = Path(audio.filename or "").suffix or ".wav"
644
+
645
+ with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
646
+ shutil.copyfileobj(audio.file, tmp)
647
+ tmp_path = tmp.name
648
+
649
+ transcript, trace = transcribe_audio(tmp_path)
650
+
651
+ state_dict["voice_result"] = {
652
+ "transcript": transcript,
653
+ "trace": trace,
654
+ }
655
+ state_dict["page"] = "add"
656
+ state_dict["active_method"] = "voice"
657
+
658
+ toast = "success|Speech transcribed" if transcript else f"warn|{trace[-1] if trace else 'Speech transcription failed'}"
659
+ html = ui_render.render(state_dict["page"], state_dict, toast=toast)
660
+ return {"html": html, "state": state_dict}
661
+
662
+
663
+ @server.get("/api/status")
664
+ def api_status() -> dict:
665
+ return _service_status()
666
+
667
+
668
+ @server.get("/api/warm")
669
+ def api_warm() -> dict:
670
+ import os
671
+ import requests
672
+
673
+ endpoints = [
674
+ os.getenv("MODAL_RECEIPT_ENDPOINT", "").strip(),
675
+ os.getenv("MINICPM_RECEIPT_ENDPOINT", "").strip(),
676
+ os.getenv("MODAL_RECEIPT_LLM_ENDPOINT", "").strip(),
677
+ os.getenv("MODAL_SPEECH_ENDPOINT", "").strip(),
678
+ os.getenv("SPEECH_ASR_ENDPOINT", "").strip(),
679
+ ]
680
+
681
+ # NLU health endpoint — derive from extract URL by swapping the label
682
+ nlu_extract = os.getenv("MODAL_NLU_ENDPOINT", "").strip()
683
+ if nlu_extract:
684
+ endpoints.append(nlu_extract.replace("nlu-extract", "nlu-health"))
685
+
686
+ warmed = 0
687
+
688
+ def _ping(url: str) -> None:
689
+ try:
690
+ requests.get(url, timeout=5)
691
+ except Exception:
692
+ pass
693
+
694
+ for endpoint in sorted({e for e in endpoints if e}):
695
+ warmed += 1
696
+ threading.Thread(target=_ping, args=(endpoint,), daemon=True).start()
697
+
698
+ return {"ok": True, "warmed": warmed}
699
+
700
+
701
+ if __name__ == "__main__":
702
+ server.launch(
703
+ server_name="0.0.0.0",
704
+ server_port=7860,
705
+ show_error=True,
706
+ )
archive/legacy/asr.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Telugu ASR using Whisper — models/asr.py"""
2
+ import logging
3
+ logger = logging.getLogger(__name__)
4
+
5
+ _whisper_model = None
6
+
7
+ def _load_whisper():
8
+ global _whisper_model
9
+ if _whisper_model is None:
10
+ import whisper
11
+ # "small" is ~244M params, good Telugu accuracy, fast on CPU
12
+ _whisper_model = whisper.load_model("small")
13
+ logger.info("Whisper small loaded")
14
+ return _whisper_model
15
+
16
+ def transcribe_telugu(audio_path: str) -> str:
17
+ """Transcribe Telugu audio file → Telugu text string."""
18
+ try:
19
+ model = _load_whisper()
20
+ result = model.transcribe(audio_path, language="te", task="transcribe")
21
+ text = result.get("text", "").strip()
22
+ logger.info(f"ASR: {text[:80]}")
23
+ return text
24
+ except Exception as e:
25
+ logger.error(f"ASR failed: {e}")
26
+ return ""
archive/legacy/database.py ADDED
@@ -0,0 +1,357 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQLite database layer.
3
+ Schema initialised on first run. All writes use parameterised queries.
4
+ """
5
+
6
+ import sqlite3
7
+ import uuid
8
+ from datetime import datetime, timedelta
9
+ from pathlib import Path
10
+
11
+ DB_PATH = Path(__file__).parent.parent / "dukaan.db"
12
+
13
+
14
+ def _conn():
15
+ conn = sqlite3.connect(DB_PATH)
16
+ conn.row_factory = sqlite3.Row
17
+ conn.execute("PRAGMA journal_mode=WAL")
18
+ return conn
19
+
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # Schema
23
+ # ---------------------------------------------------------------------------
24
+
25
+ SCHEMA = """
26
+ CREATE TABLE IF NOT EXISTS products (
27
+ id TEXT PRIMARY KEY,
28
+ name TEXT NOT NULL,
29
+ variant TEXT,
30
+ supplier_id TEXT,
31
+ unit_type TEXT DEFAULT 'unit',
32
+ units_per_case INTEGER DEFAULT 1,
33
+ reorder_threshold INTEGER DEFAULT 2,
34
+ last_unit_cost REAL DEFAULT 0,
35
+ expiry_tracked INTEGER DEFAULT 0,
36
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP
37
+ );
38
+
39
+ CREATE TABLE IF NOT EXISTS aliases (
40
+ alias TEXT PRIMARY KEY,
41
+ product_id TEXT NOT NULL,
42
+ FOREIGN KEY (product_id) REFERENCES products(id)
43
+ );
44
+
45
+ CREATE TABLE IF NOT EXISTS suppliers (
46
+ id TEXT PRIMARY KEY,
47
+ name TEXT NOT NULL,
48
+ gstin TEXT,
49
+ phone TEXT,
50
+ min_order_value REAL DEFAULT 0,
51
+ avg_lead_days INTEGER DEFAULT 2
52
+ );
53
+
54
+ CREATE TABLE IF NOT EXISTS stock_ledger (
55
+ id TEXT PRIMARY KEY,
56
+ product_id TEXT NOT NULL,
57
+ delta INTEGER NOT NULL,
58
+ event_type TEXT NOT NULL,
59
+ source_doc TEXT,
60
+ unit_cost REAL DEFAULT 0,
61
+ recorded_at TEXT DEFAULT CURRENT_TIMESTAMP,
62
+ FOREIGN KEY (product_id) REFERENCES products(id)
63
+ );
64
+
65
+ CREATE TABLE IF NOT EXISTS sales_log (
66
+ id TEXT PRIMARY KEY,
67
+ product_id TEXT NOT NULL,
68
+ qty_sold INTEGER NOT NULL,
69
+ unit_price REAL DEFAULT 0,
70
+ note_ref TEXT,
71
+ sold_date TEXT DEFAULT CURRENT_DATE,
72
+ FOREIGN KEY (product_id) REFERENCES products(id)
73
+ );
74
+
75
+ CREATE TABLE IF NOT EXISTS purchase_orders (
76
+ id TEXT PRIMARY KEY,
77
+ supplier_id TEXT NOT NULL,
78
+ supplier_name TEXT,
79
+ status TEXT DEFAULT 'pending',
80
+ items_json TEXT,
81
+ po_total REAL DEFAULT 0,
82
+ created_at TEXT DEFAULT CURRENT_TIMESTAMP,
83
+ approved_at TEXT,
84
+ FOREIGN KEY (supplier_id) REFERENCES suppliers(id)
85
+ );
86
+
87
+ CREATE TABLE IF NOT EXISTS policies (
88
+ id TEXT PRIMARY KEY,
89
+ rule_type TEXT NOT NULL,
90
+ target TEXT,
91
+ value_json TEXT,
92
+ active INTEGER DEFAULT 1
93
+ );
94
+
95
+ CREATE VIEW IF NOT EXISTS current_stock AS
96
+ SELECT
97
+ p.id,
98
+ p.name,
99
+ p.supplier_id,
100
+ p.reorder_threshold,
101
+ p.units_per_case,
102
+ COALESCE(SUM(sl.delta), 0) AS current_units
103
+ FROM products p
104
+ LEFT JOIN stock_ledger sl ON sl.product_id = p.id
105
+ GROUP BY p.id;
106
+ """
107
+
108
+ SEED_DATA = """
109
+ INSERT OR IGNORE INTO suppliers VALUES
110
+ ('sup_mahalakshmi', 'Mahalakshmi Marketing', '36RSLPS0259D1Z6', '7300000000', 2000, 1),
111
+ ('sup_venkateshwara', 'Sri Venkateshwara Marketing', '36AZLIPV6442K12M', '9959404640', 5000, 2);
112
+
113
+ INSERT OR IGNORE INTO products VALUES
114
+ ('parle_g_100g', 'Parle-G 100g', NULL, 'sup_venkateshwara', 'case', 24, 3, 8.625, 0, CURRENT_TIMESTAMP),
115
+ ('bingo_c', 'Bingo (C)', NULL, 'sup_mahalakshmi', 'case', 12, 2, 870.0, 0, CURRENT_TIMESTAMP),
116
+ ('happy_24p', 'Happy (24P)', NULL, 'sup_venkateshwara', 'case', 24, 3, 4.464, 0, CURRENT_TIMESTAMP),
117
+ ('parle_bulk', 'Parle (bulk)', NULL, 'sup_mahalakshmi', 'case', 1, 1, 2450.0,0, CURRENT_TIMESTAMP);
118
+
119
+ INSERT OR IGNORE INTO aliases VALUES
120
+ ('Bm', 'bingo_c'),
121
+ ('bingo', 'bingo_c'),
122
+ ('Bingo(C)', 'bingo_c'),
123
+ ('Bingo (C)', 'bingo_c'),
124
+ ('parle', 'parle_g_100g'),
125
+ ('parle-g', 'parle_g_100g'),
126
+ ('Parle-G', 'parle_g_100g'),
127
+ ('happy', 'happy_24p'),
128
+ ('Happy 2', 'happy_24p');
129
+
130
+ INSERT OR IGNORE INTO policies VALUES
131
+ ('pol_min_order_maha', 'min_order', 'sup_mahalakshmi', '{"value": 2000}', 1),
132
+ ('pol_min_order_venk', 'min_order', 'sup_venkateshwara', '{"value": 5000}', 1),
133
+ ('pol_price_spike', 'price_spike_alert_pct', NULL, '{"value": 10}', 1);
134
+ """
135
+
136
+
137
+ def init_db():
138
+ with _conn() as conn:
139
+ conn.executescript(SCHEMA)
140
+ conn.executescript(SEED_DATA)
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # Reads
145
+ # ---------------------------------------------------------------------------
146
+
147
+ def get_stock_levels() -> list:
148
+ with _conn() as conn:
149
+ rows = conn.execute("""
150
+ SELECT
151
+ p.name,
152
+ s.name AS supplier,
153
+ cs.current_units || ' units' AS stock,
154
+ p.reorder_threshold || ' units' AS threshold,
155
+ CASE
156
+ WHEN cs.current_units <= 0 THEN 'అయిపోయింది'
157
+ WHEN cs.current_units <= p.reorder_threshold THEN 'తక్కువగా'
158
+ ELSE 'OK'
159
+ END AS status
160
+ FROM current_stock cs
161
+ JOIN products p ON p.id = cs.id
162
+ LEFT JOIN suppliers s ON s.id = p.supplier_id
163
+ ORDER BY cs.current_units ASC
164
+ """).fetchall()
165
+ return [list(r) for r in rows]
166
+
167
+
168
+ def get_products_below_threshold() -> list:
169
+ with _conn() as conn:
170
+ rows = conn.execute("""
171
+ SELECT cs.id AS product_id, p.name, p.supplier_id,
172
+ cs.current_units AS current_stock,
173
+ p.reorder_threshold, p.last_unit_cost
174
+ FROM current_stock cs
175
+ JOIN products p ON p.id = cs.id
176
+ WHERE cs.current_units <= p.reorder_threshold
177
+ """).fetchall()
178
+ return [dict(r) for r in rows]
179
+
180
+
181
+ def get_product(product_id: str) -> dict | None:
182
+ with _conn() as conn:
183
+ row = conn.execute(
184
+ "SELECT * FROM products WHERE id = ?", (product_id,)
185
+ ).fetchone()
186
+ return dict(row) if row else None
187
+
188
+
189
+ def get_supplier(supplier_id: str) -> dict | None:
190
+ with _conn() as conn:
191
+ row = conn.execute(
192
+ "SELECT * FROM suppliers WHERE id = ?", (supplier_id,)
193
+ ).fetchone()
194
+ return dict(row) if row else None
195
+
196
+
197
+ def get_policies() -> dict:
198
+ with _conn() as conn:
199
+ rows = conn.execute(
200
+ "SELECT rule_type, target, value_json FROM policies WHERE active=1"
201
+ ).fetchall()
202
+ import json
203
+ result: dict = {"min_order_per_supplier": {}}
204
+ for r in rows:
205
+ val = json.loads(r["value_json"] or "{}").get("value")
206
+ if r["rule_type"] == "min_order" and r["target"]:
207
+ sup = get_supplier(r["target"])
208
+ if sup:
209
+ result["min_order_per_supplier"][sup["name"]] = val
210
+ else:
211
+ result[r["rule_type"]] = val
212
+ return result
213
+
214
+
215
+ def get_last_unit_cost(product_id: str) -> float | None:
216
+ with _conn() as conn:
217
+ row = conn.execute(
218
+ """SELECT unit_cost FROM stock_ledger
219
+ WHERE product_id=? AND event_type='receipt' AND unit_cost > 0
220
+ ORDER BY recorded_at DESC LIMIT 1""",
221
+ (product_id,),
222
+ ).fetchone()
223
+ return row["unit_cost"] if row else None
224
+
225
+
226
+ def get_pending_pos() -> list:
227
+ with _conn() as conn:
228
+ rows = conn.execute(
229
+ """SELECT id, supplier_name, items_json, po_total
230
+ FROM purchase_orders WHERE status='pending'
231
+ ORDER BY created_at DESC"""
232
+ ).fetchall()
233
+ result = []
234
+ import json
235
+ for r in rows:
236
+ items = json.loads(r["items_json"] or "[]")
237
+ item_summary = ", ".join(
238
+ f"{i.get('product_name','?')} ×{i.get('suggested_qty_cases','?')}"
239
+ for i in items
240
+ )
241
+ reason_te = "; ".join(i.get("reason_te", "") for i in items)
242
+ result.append([r["id"], r["supplier_name"], item_summary,
243
+ f"₹{r['po_total']:.0f}", reason_te])
244
+ return result
245
+
246
+
247
+ def get_weekly_summary() -> dict:
248
+ week_ago = (datetime.now() - timedelta(days=7)).isoformat()
249
+ with _conn() as conn:
250
+ purchases = conn.execute(
251
+ """SELECT COALESCE(SUM(delta * unit_cost), 0) as total
252
+ FROM stock_ledger WHERE event_type='receipt' AND recorded_at >= ?""",
253
+ (week_ago,),
254
+ ).fetchone()["total"]
255
+ sales_qty = conn.execute(
256
+ """SELECT COALESCE(SUM(ABS(delta)), 0) as qty
257
+ FROM stock_ledger WHERE event_type='sale' AND recorded_at >= ?""",
258
+ (week_ago,),
259
+ ).fetchone()["qty"]
260
+ return {"purchases_inr": purchases, "units_sold": sales_qty, "period": "7 days"}
261
+
262
+
263
+ def get_shrinkage_report() -> dict:
264
+ with _conn() as conn:
265
+ received = conn.execute(
266
+ "SELECT COALESCE(SUM(delta),0) FROM stock_ledger WHERE event_type='receipt'"
267
+ ).fetchone()[0]
268
+ sold = abs(conn.execute(
269
+ "SELECT COALESCE(SUM(delta),0) FROM stock_ledger WHERE event_type='sale'"
270
+ ).fetchone()[0])
271
+ current = conn.execute(
272
+ "SELECT COALESCE(SUM(current_units),0) FROM current_stock"
273
+ ).fetchone()[0]
274
+ expected = received - sold
275
+ shrinkage = expected - current
276
+ pct = (shrinkage / received * 100) if received else 0
277
+ return {
278
+ "received": received, "sold": sold,
279
+ "expected_on_shelf": expected, "actual_on_shelf": current,
280
+ "shrinkage_units": shrinkage, "shrinkage_pct": round(pct, 2),
281
+ }
282
+
283
+
284
+ def get_cost_vs_revenue() -> dict:
285
+ with _conn() as conn:
286
+ cost = conn.execute(
287
+ "SELECT COALESCE(SUM(ABS(delta)*unit_cost),0) FROM stock_ledger WHERE event_type='receipt'"
288
+ ).fetchone()[0]
289
+ revenue = conn.execute(
290
+ "SELECT COALESCE(SUM(qty_sold*unit_price),0) FROM sales_log"
291
+ ).fetchone()[0]
292
+ return {"total_cost_inr": cost, "estimated_revenue_inr": revenue,
293
+ "gross_margin_inr": revenue - cost}
294
+
295
+
296
+ # ---------------------------------------------------------------------------
297
+ # Writes
298
+ # ---------------------------------------------------------------------------
299
+
300
+ def update_stock(product_id, delta, event_type, source_doc, unit_cost=0):
301
+ with _conn() as conn:
302
+ conn.execute(
303
+ """INSERT INTO stock_ledger (id, product_id, delta, event_type, source_doc, unit_cost)
304
+ VALUES (?, ?, ?, ?, ?, ?)""",
305
+ (str(uuid.uuid4()), product_id, delta, event_type, source_doc, unit_cost),
306
+ )
307
+ if unit_cost > 0 and event_type == "receipt":
308
+ conn.execute(
309
+ "UPDATE products SET last_unit_cost=? WHERE id=?",
310
+ (unit_cost, product_id),
311
+ )
312
+
313
+
314
+ def log_receipt(receipt: dict) -> str:
315
+ doc_id = str(uuid.uuid4())
316
+ import json
317
+ with _conn() as conn:
318
+ conn.execute(
319
+ """INSERT INTO stock_ledger (id, product_id, delta, event_type, source_doc)
320
+ VALUES (?, 'raw_receipt', 0, 'receipt_log', ?)""",
321
+ (doc_id, json.dumps(receipt, ensure_ascii=False)),
322
+ )
323
+ return doc_id
324
+
325
+
326
+ def save_pending_po(po: dict) -> str:
327
+ import json
328
+ po_id = f"PO-{str(uuid.uuid4())[:8].upper()}"
329
+ with _conn() as conn:
330
+ conn.execute(
331
+ """INSERT INTO purchase_orders
332
+ (id, supplier_id, supplier_name, status, items_json, po_total)
333
+ VALUES (?, ?, ?, 'pending', ?, ?)""",
334
+ (
335
+ po_id,
336
+ po.get("supplier_id", "unknown"),
337
+ po.get("supplier_name", "Unknown"),
338
+ json.dumps(po.get("items", []), ensure_ascii=False),
339
+ po.get("po_total", 0),
340
+ ),
341
+ )
342
+ return po_id
343
+
344
+
345
+ def approve_purchase_order(po_id: str):
346
+ with _conn() as conn:
347
+ conn.execute(
348
+ "UPDATE purchase_orders SET status='approved', approved_at=CURRENT_TIMESTAMP WHERE id=?",
349
+ (po_id,),
350
+ )
351
+
352
+
353
+ def reject_purchase_order(po_id: str):
354
+ with _conn() as conn:
355
+ conn.execute(
356
+ "UPDATE purchase_orders SET status='rejected' WHERE id=?", (po_id,)
357
+ )
archive/legacy/finetune_receipt.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ finetune_receipt.py — LoRA finetune Mistral-7B on receipt line-item extraction.
3
+
4
+ Training data: photos/OCR text from Mahalakshmi Marketing and Sri Venkateshwara
5
+ receipts, paired with correct structured JSON output.
6
+
7
+ Run on HF free GPU (T4) or locally:
8
+ python finetune_receipt.py
9
+
10
+ Output: ./mistral-7b-receipt-lora/ (merged GGUF exported at the end)
11
+
12
+ Uses Unsloth for 2x faster training with 60% less VRAM.
13
+ """
14
+
15
+ import json
16
+ from pathlib import Path
17
+
18
+ # ── Training dataset ─────────────────────────────────────────────────────────
19
+ # Format: list of {"input": "<ocr_text>", "output": "<json_string>"}
20
+ # Expand this with more receipts as you photograph them.
21
+
22
+ TRAINING_EXAMPLES = [
23
+ {
24
+ "input": """MAHALAKSHMI MARKETING
25
+ No. 2816 Date: 27/5/26
26
+ M/s. Veerabala (Mulal)
27
+ Parle 1 X 2450 = 2450
28
+ Bingo(C) 4 X 870 = 3480
29
+ Subtotal 5930
30
+ Discount 612
31
+ Total 6542""",
32
+ "output": json.dumps({
33
+ "supplier": "Mahalakshmi Marketing",
34
+ "invoice_no": "2816",
35
+ "date": "2026-05-27",
36
+ "items": [
37
+ {"product_raw": "Parle", "qty_cases": 1, "qty_units": 1,
38
+ "unit_cost": 2450.0, "total": 2450.0},
39
+ {"product_raw": "Bingo(C)", "qty_cases": 4, "qty_units": 4,
40
+ "unit_cost": 870.0, "total": 3480.0},
41
+ ],
42
+ "subtotal": 5930.0, "discount": 612.0, "gst": 0.0, "net_total": 6542.0,
43
+ }),
44
+ },
45
+ {
46
+ "input": """SRI VENKATESHWARA MARKETING
47
+ GSTIN: 36AZLIPV6442K12M
48
+ CUSTOMER: VEERA BHADRA WS
49
+ Bill Date: 28/05/2026
50
+ 1 PARLE-G 100G QTY: 5/0 MRP: 10 SALE RATE: 8.625
51
+ 2 HAPPY 2 (24P)*13 QTY: 10/0 MRP: 9 SALE RATE: 4.464
52
+ GROSS SALES: 8569.032
53
+ SCHEMES: 168.352
54
+ CASH DISC: 420.034
55
+ GST: 210.017 SGST: 210.017
56
+ NET AMOUNT: 8821.00""",
57
+ "output": json.dumps({
58
+ "supplier": "Sri Venkateshwara Marketing",
59
+ "invoice_no": "SVM/26-27/2598",
60
+ "date": "2026-05-28",
61
+ "items": [
62
+ {"product_raw": "PARLE-G 100G", "qty_cases": 5, "qty_units": 120,
63
+ "unit_cost": 8.625, "total": 1035.0},
64
+ {"product_raw": "HAPPY 2 (24P)", "qty_cases": 10, "qty_units": 240,
65
+ "unit_cost": 4.464, "total": 1071.36},
66
+ ],
67
+ "subtotal": 8569.032, "discount": 588.386, "gst": 420.034, "net_total": 8821.0,
68
+ }),
69
+ },
70
+ {
71
+ "input": """Brundhna Boys - 28/05
72
+ hne 30X28 = 840
73
+ oam 50X9.5 450
74
+ Bm 10X9.5 95
75
+ Bm 5X12 50
76
+ Total 1435""",
77
+ "output": json.dumps({
78
+ "supplier": "sales_note",
79
+ "invoice_no": None,
80
+ "date": "2026-05-28",
81
+ "items": [
82
+ {"product_raw": "hne", "qty_cases": 0, "qty_units": 30, "unit_cost": 28.0, "total": 840.0},
83
+ {"product_raw": "oam", "qty_cases": 0, "qty_units": 50, "unit_cost": 9.5, "total": 450.0},
84
+ {"product_raw": "Bm", "qty_cases": 0, "qty_units": 10, "unit_cost": 9.5, "total": 95.0},
85
+ {"product_raw": "Bm", "qty_cases": 0, "qty_units": 5, "unit_cost": 12.0, "total": 50.0},
86
+ ],
87
+ "subtotal": 1435.0, "discount": 0.0, "gst": 0.0, "net_total": 1435.0,
88
+ }),
89
+ },
90
+ ]
91
+
92
+ SYSTEM_PROMPT = """You are a receipt parser for an Indian convenience store.
93
+ Extract all line items from the receipt text. Return ONLY valid JSON, no markdown."""
94
+
95
+ INSTRUCTION_TEMPLATE = """### Instruction:
96
+ {system}
97
+
98
+ ### Input:
99
+ {input}
100
+
101
+ ### Response:
102
+ {output}"""
103
+
104
+
105
+ def build_dataset():
106
+ """Convert examples to Unsloth instruction format."""
107
+ return [
108
+ {
109
+ "text": INSTRUCTION_TEMPLATE.format(
110
+ system=SYSTEM_PROMPT,
111
+ input=ex["input"],
112
+ output=ex["output"],
113
+ )
114
+ }
115
+ for ex in TRAINING_EXAMPLES
116
+ ]
117
+
118
+
119
+ def finetune():
120
+ from unsloth import FastLanguageModel
121
+ from trl import SFTTrainer
122
+ from transformers import TrainingArguments
123
+ from datasets import Dataset
124
+
125
+ print("Loading base model with Unsloth...")
126
+ model, tokenizer = FastLanguageModel.from_pretrained(
127
+ model_name="unsloth/mistral-7b-instruct-v0.2-bnb-4bit",
128
+ max_seq_length=2048,
129
+ dtype=None,
130
+ load_in_4bit=True,
131
+ )
132
+
133
+ # Apply LoRA
134
+ model = FastLanguageModel.get_peft_model(
135
+ model,
136
+ r=16,
137
+ target_modules=["q_proj", "k_proj", "v_proj", "o_proj",
138
+ "gate_proj", "up_proj", "down_proj"],
139
+ lora_alpha=16,
140
+ lora_dropout=0.05,
141
+ bias="none",
142
+ use_gradient_checkpointing=True,
143
+ )
144
+
145
+ dataset = Dataset.from_list(build_dataset())
146
+ print(f"Training on {len(dataset)} examples")
147
+
148
+ trainer = SFTTrainer(
149
+ model=model,
150
+ tokenizer=tokenizer,
151
+ train_dataset=dataset,
152
+ dataset_text_field="text",
153
+ max_seq_length=2048,
154
+ args=TrainingArguments(
155
+ per_device_train_batch_size=2,
156
+ gradient_accumulation_steps=4,
157
+ num_train_epochs=10, # small dataset → more epochs
158
+ learning_rate=2e-4,
159
+ fp16=True,
160
+ logging_steps=1,
161
+ output_dir="./mistral-7b-receipt-lora",
162
+ save_strategy="epoch",
163
+ warmup_steps=5,
164
+ optim="adamw_8bit",
165
+ ),
166
+ )
167
+
168
+ trainer.train()
169
+ print("Training done. Saving LoRA weights...")
170
+ model.save_pretrained("./mistral-7b-receipt-lora")
171
+ tokenizer.save_pretrained("./mistral-7b-receipt-lora")
172
+
173
+ # Export merged GGUF for llama.cpp
174
+ print("Exporting merged GGUF (Q4_K_M)...")
175
+ model.save_pretrained_gguf(
176
+ "mistral-7b-receipt",
177
+ tokenizer,
178
+ quantization_method="q4_k_m",
179
+ )
180
+ print("Done! Upload mistral-7b-receipt-unsloth.Q4_K_M.gguf to your HF Space model dir.")
181
+
182
+
183
+ if __name__ == "__main__":
184
+ finetune()
archive/legacy/graph.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LangGraph graph definition.
3
+
4
+ Topology:
5
+ orchestrator
6
+ ├─(receipt_parse)──► receipt_parser ──► inventory_manager ──► po_check
7
+ ├─(reorder_trigger)─► inventory_manager ──► reorder_agent ──► po_check
8
+ ├─(sales_log)───────► inventory_manager ──► END
9
+ ├─(stock_query)─────► inventory_manager ──► END
10
+ └─(report)──────────► reporting_agent ──► END
11
+
12
+ po_check:
13
+ if po_draft exists → HITL pause node (streams draft to UI, waits)
14
+ else → END
15
+ """
16
+
17
+ from langgraph.graph import StateGraph, END
18
+
19
+ from state import AgentState
20
+ from orchestrator import orchestrator_node
21
+ from receipt_parser import receipt_parser_node
22
+ from inventory_manager import inventory_manager_node
23
+ from reorder_agent import reorder_agent_node
24
+ from reporting_agent import reporting_agent_node
25
+ from po_check import po_check_node
26
+
27
+
28
+ def route_from_orchestrator(state: AgentState) -> str:
29
+ """Conditional edge: orchestrator → sub-agent based on classified intent."""
30
+ intent: str = state.get("intent") or "stock_query"
31
+ routes = {
32
+ "receipt_parse": "receipt_parser",
33
+ "reorder_trigger": "inventory_manager",
34
+ "sales_log": "inventory_manager",
35
+ "stock_query": "inventory_manager",
36
+ "report": "reporting_agent",
37
+ }
38
+ return routes.get(intent, "inventory_manager")
39
+
40
+
41
+ def route_after_inventory(state: AgentState) -> str:
42
+ """After inventory manager: go to reorder if that was the intent."""
43
+ intent: str = state.get("intent") or "stock_query"
44
+ if intent in ("reorder_trigger", "receipt_parse"):
45
+ return "reorder_agent"
46
+ return "po_check"
47
+
48
+
49
+ def route_po_check(state: AgentState) -> str:
50
+ """If a PO draft exists, surface it; otherwise finish."""
51
+ if state.get("po_draft"):
52
+ return "po_check"
53
+ return END
54
+
55
+
56
+ def build_graph() -> StateGraph:
57
+ g = StateGraph(AgentState)
58
+
59
+ # Register nodes
60
+ g.add_node("orchestrator", orchestrator_node)
61
+ g.add_node("receipt_parser", receipt_parser_node)
62
+ g.add_node("inventory_manager", inventory_manager_node)
63
+ g.add_node("reorder_agent", reorder_agent_node)
64
+ g.add_node("reporting_agent", reporting_agent_node)
65
+ g.add_node("po_check", po_check_node)
66
+
67
+ # Entry point
68
+ g.set_entry_point("orchestrator")
69
+
70
+ # Orchestrator → sub-agents (conditional)
71
+ g.add_conditional_edges(
72
+ "orchestrator",
73
+ route_from_orchestrator,
74
+ {
75
+ "receipt_parser": "receipt_parser",
76
+ "inventory_manager": "inventory_manager",
77
+ "reporting_agent": "reporting_agent",
78
+ },
79
+ )
80
+
81
+ # Receipt parser always feeds inventory manager
82
+ g.add_edge("receipt_parser", "inventory_manager")
83
+
84
+ # Inventory manager branches
85
+ g.add_conditional_edges(
86
+ "inventory_manager",
87
+ route_after_inventory,
88
+ {
89
+ "reorder_agent": "reorder_agent",
90
+ "po_check": "po_check",
91
+ },
92
+ )
93
+
94
+ # Reorder agent → po_check
95
+ g.add_edge("reorder_agent", "po_check")
96
+
97
+ # Reporting agent → END
98
+ g.add_edge("reporting_agent", END)
99
+
100
+ # po_check → END (HITL pause is handled inside the node)
101
+ g.add_edge("po_check", END)
102
+
103
+ return g.compile()
archive/legacy/inventory_manager.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inventory manager node.
3
+
4
+ Handles:
5
+ - Updating stock levels from parsed receipts (delta = +qty received)
6
+ - Updating stock levels from sales notes (delta = -qty sold)
7
+ - Answering stock queries
8
+ - Checking thresholds and flagging products needing reorder
9
+ """
10
+
11
+ from state import AgentState
12
+ from db.database import (
13
+ update_stock,
14
+ get_stock_levels,
15
+ get_products_below_threshold,
16
+ )
17
+
18
+ import logging
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ def inventory_manager_node(state: AgentState) -> AgentState:
23
+ trace = state.get("trace", [])
24
+ intent = state.get("intent", "stock_query")
25
+ policies = state.get("active_policies", {})
26
+
27
+ # ── Receipt parse: add incoming stock ────────────────────────────────────
28
+ if intent == "receipt_parse" and state.get("structured_data"):
29
+ items = state["structured_data"].get("items", [])
30
+ for item in items:
31
+ pid = item.get("product_id")
32
+ if not pid:
33
+ continue
34
+ # Convert cases → units using products table
35
+ qty_units = item.get("qty_units", 0) or (
36
+ item.get("qty_cases", 0) * _units_per_case(pid)
37
+ )
38
+ update_stock(
39
+ product_id=pid,
40
+ delta=+qty_units,
41
+ event_type="receipt",
42
+ source_doc=state["structured_data"].get("invoice_no", "unknown"),
43
+ unit_cost=item.get("unit_cost", 0),
44
+ )
45
+ trace.append(f"inventory_mgr: +{qty_units} units → {pid}")
46
+
47
+ # ── Sales log: subtract sold stock ──────────────────────────────────────
48
+ elif intent == "sales_log" and state.get("structured_data"):
49
+ items = state["structured_data"].get("items", [])
50
+ for item in items:
51
+ pid = item.get("product_id")
52
+ qty = item.get("qty_units", 0)
53
+ if pid and qty:
54
+ update_stock(
55
+ product_id=pid,
56
+ delta=-qty,
57
+ event_type="sale",
58
+ source_doc="sales_note",
59
+ unit_cost=0,
60
+ )
61
+ trace.append(f"inventory_mgr: -{qty} units → {pid}")
62
+
63
+ # ── Stock query: build a natural-language response ───────────────────────
64
+ elif intent == "stock_query":
65
+ rows = get_stock_levels()
66
+ summary_lines = [f"{r[0]}: {r[2]} (threshold {r[3]})" for r in rows[:10]]
67
+ state = {
68
+ **state,
69
+ "response": "Current stock levels:\n" + "\n".join(summary_lines),
70
+ }
71
+ trace.append("inventory_mgr: stock query answered")
72
+
73
+ # ── Check thresholds → flag reorder candidates ───────────────────────────
74
+ below = get_products_below_threshold()
75
+ if below:
76
+ names = [p["name"] for p in below]
77
+ trace.append(f"inventory_mgr: {len(below)} products below threshold — {names}")
78
+ # Store for reorder agent to consume
79
+ state = {**state, "structured_data": {
80
+ **(state.get("structured_data") or {}),
81
+ "below_threshold": below,
82
+ }}
83
+
84
+ # Policy: flag if any price on this receipt is >10% above history
85
+ price_spike_pct = policies.get("price_spike_alert_pct", 10)
86
+ _check_price_spikes(state, price_spike_pct, trace)
87
+
88
+ return {**state, "trace": trace}
89
+
90
+
91
+ def _units_per_case(product_id: str) -> int:
92
+ from db.database import get_product
93
+ p = get_product(product_id)
94
+ return p.get("units_per_case", 1) if p else 1
95
+
96
+
97
+ def _check_price_spikes(state: dict, threshold_pct: float, trace: list):
98
+ from db.database import get_last_unit_cost
99
+ items = (state.get("structured_data") or {}).get("items", [])
100
+ for item in items:
101
+ pid = item.get("product_id")
102
+ new_cost = item.get("unit_cost", 0)
103
+ if not pid or not new_cost:
104
+ continue
105
+ last_cost = get_last_unit_cost(pid)
106
+ if last_cost and last_cost > 0:
107
+ pct_change = ((new_cost - last_cost) / last_cost) * 100
108
+ if pct_change > threshold_pct:
109
+ trace.append(
110
+ f"inventory_mgr: PRICE SPIKE {pid} "
111
+ f"₹{last_cost:.0f}→₹{new_cost:.0f} (+{pct_change:.1f}%)"
112
+ )
archive/legacy/llm.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM wrapper — calls the llama.cpp HTTP server running on localhost.
3
+
4
+ llama.cpp is started as a background process in the HF Space via startup.sh.
5
+ Each model is loaded as a separate server instance on different ports:
6
+
7
+ Port 8080 — llama-3.2-3b-instruct.Q4_K_M.gguf (orchestrator)
8
+ Port 8081 — mistral-7b-instruct.Q4_K_M.gguf (inventory, reorder, report)
9
+ Port 8082 — mistral-7b-receipt-lora.Q4_K_M.gguf (finetuned receipt parser)
10
+
11
+ All models are served via the OpenAI-compatible /v1/chat/completions endpoint.
12
+ """
13
+
14
+ import json
15
+ import logging
16
+ from typing import Optional
17
+ import urllib.request
18
+ import urllib.error
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+ MODEL_PORTS = {
23
+ "llama-3.2-3b": 8080,
24
+ "mistral-7b": 8081,
25
+ "mistral-7b-receipt": 8082, # LoRA-merged GGUF
26
+ }
27
+
28
+ DEFAULT_MODEL = "mistral-7b"
29
+
30
+
31
+ class Session:
32
+ """
33
+ Stateful conversation session.
34
+
35
+ Keeps the full inventory dict in the system prompt so the model always
36
+ sees current stock. Only the last `history_turns` exchanges are sent as
37
+ message history — enough for follow-up questions without unbounded growth.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ model: str = DEFAULT_MODEL,
43
+ system: str = "",
44
+ inventory: Optional[dict] = None,
45
+ history_turns: int = 3,
46
+ **llm_kwargs,
47
+ ):
48
+ self.model = model
49
+ self._base_system = system
50
+ self.inventory: dict = inventory or {}
51
+ self.history_turns = history_turns
52
+ self.llm_kwargs = llm_kwargs
53
+ self._recent: list[dict] = []
54
+
55
+ # ------------------------------------------------------------------
56
+ # Inventory management
57
+ # ------------------------------------------------------------------
58
+
59
+ def set_inventory(self, inventory: dict) -> None:
60
+ """Replace the full inventory state."""
61
+ self.inventory = inventory
62
+
63
+ def update_inventory(self, updates: dict) -> None:
64
+ """Merge updates into the inventory (shallow merge by SKU key)."""
65
+ self.inventory.update(updates)
66
+
67
+ # ------------------------------------------------------------------
68
+ # Chat
69
+ # ------------------------------------------------------------------
70
+
71
+ def chat(self, user: str) -> str:
72
+ self._recent.append({"role": "user", "content": user})
73
+ history = self._recent[-(self.history_turns * 2):]
74
+ reply = _call_with_history(
75
+ self.model, self._system_prompt(), history, **self.llm_kwargs
76
+ )
77
+ self._recent.append({"role": "assistant", "content": reply})
78
+ return reply
79
+
80
+ def reset_history(self) -> None:
81
+ """Clear conversation history without touching the inventory."""
82
+ self._recent.clear()
83
+
84
+ # ------------------------------------------------------------------
85
+ # Internal
86
+ # ------------------------------------------------------------------
87
+
88
+ def _system_prompt(self) -> str:
89
+ inventory_block = json.dumps(self.inventory, indent=2) if self.inventory else "empty"
90
+ parts = [self._base_system] if self._base_system else []
91
+ parts.append(f"Current inventory:\n{inventory_block}")
92
+ return "\n\n".join(parts)
93
+
94
+
95
+ def _call_with_history(
96
+ model: str,
97
+ system: str,
98
+ history: list[dict],
99
+ max_tokens: int = 512,
100
+ json_mode: bool = False,
101
+ temperature: float = 0.1,
102
+ ) -> str:
103
+ port = MODEL_PORTS.get(model, MODEL_PORTS[DEFAULT_MODEL])
104
+ url = f"http://localhost:{port}/v1/chat/completions"
105
+
106
+ messages = ([{"role": "system", "content": system}] if system else []) + history
107
+
108
+ payload = {
109
+ "model": model,
110
+ "messages": messages,
111
+ "max_tokens": max_tokens,
112
+ "temperature": temperature,
113
+ "stream": False,
114
+ }
115
+ if json_mode:
116
+ payload["response_format"] = {"type": "json_object"}
117
+
118
+ try:
119
+ data = json.dumps(payload).encode("utf-8")
120
+ req = urllib.request.Request(
121
+ url,
122
+ data=data,
123
+ headers={"Content-Type": "application/json"},
124
+ method="POST",
125
+ )
126
+ with urllib.request.urlopen(req, timeout=60) as resp:
127
+ body = json.loads(resp.read().decode("utf-8"))
128
+ return body["choices"][0]["message"]["content"]
129
+
130
+ except urllib.error.URLError as e:
131
+ logger.error(f"llama.cpp unreachable at port {port}: {e}")
132
+ if port != MODEL_PORTS[DEFAULT_MODEL]:
133
+ logger.warning(f"Retrying with default model on port {MODEL_PORTS[DEFAULT_MODEL]}")
134
+ return _call_with_history(DEFAULT_MODEL, system, history, max_tokens, json_mode, temperature)
135
+ return '{"error": "llama.cpp server unavailable"}'
136
+
137
+ except (KeyError, json.JSONDecodeError) as e:
138
+ logger.error(f"Unexpected response from llama.cpp: {e}")
139
+ return '{"error": "malformed response"}'
140
+
141
+
142
+ def call_llm(
143
+ model: str,
144
+ system: str,
145
+ user: str,
146
+ max_tokens: int = 512,
147
+ json_mode: bool = False,
148
+ temperature: float = 0.1,
149
+ ) -> str:
150
+ """
151
+ Call llama.cpp HTTP server (single-turn, stateless).
152
+ Returns the assistant message content string.
153
+ Falls back to DEFAULT_MODEL if the requested model port is unavailable.
154
+ """
155
+ return _call_with_history(
156
+ model, system,
157
+ [{"role": "user", "content": user}],
158
+ max_tokens=max_tokens,
159
+ json_mode=json_mode,
160
+ temperature=temperature,
161
+ )
archive/legacy/ocr.py ADDED
@@ -0,0 +1,86 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Receipt image OCR using Qwen2.5-VL-7B-Instruct.
3
+ Converts a photo of a receipt (handwritten or printed) into structured text,
4
+ which is then passed to the receipt_parser LLM node.
5
+ """
6
+
7
+ import base64
8
+ import logging
9
+ from pathlib import Path
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _vl_model = None
14
+ _vl_processor = None
15
+
16
+ OCR_PROMPT = """Look at this receipt image from an Indian convenience store supplier.
17
+ Extract ALL text you can see, preserving:
18
+ - Supplier name and GST number
19
+ - Invoice/receipt number and date
20
+ - Each line item: product name, quantity, rate/price, and amount
21
+ - Any totals, discounts, GST amounts
22
+
23
+ Write out the full text exactly as it appears. Product names will be in English.
24
+ Numbers may be written as multiplications like '4×870=3480'."""
25
+
26
+
27
+ def _load_vl_model():
28
+ global _vl_model, _vl_processor
29
+ if _vl_model is None:
30
+ from transformers import Qwen2VLForConditionalGeneration, AutoProcessor
31
+ name = "Qwen/Qwen2.5-VL-7B-Instruct"
32
+ _vl_processor = AutoProcessor.from_pretrained(name)
33
+ _vl_model = Qwen2VLForConditionalGeneration.from_pretrained(
34
+ name,
35
+ torch_dtype="auto",
36
+ device_map="auto",
37
+ )
38
+ logger.info("Qwen2.5-VL-7B loaded")
39
+ return _vl_processor, _vl_model
40
+
41
+
42
+ def parse_receipt_image(image_path: str) -> dict:
43
+ """
44
+ Run vision model on receipt photo.
45
+ Returns dict with 'raw_text' (OCR output) and basic structured fields.
46
+ The receipt_parser LLM node does the full structured extraction from raw_text.
47
+ """
48
+ if not image_path or not Path(image_path).exists():
49
+ return {"raw_text": "", "error": "No image provided"}
50
+
51
+ try:
52
+ processor, model = _load_vl_model()
53
+
54
+ # Encode image as base64
55
+ with open(image_path, "rb") as f:
56
+ img_b64 = base64.b64encode(f.read()).decode("utf-8")
57
+
58
+ # Detect mime type
59
+ suffix = Path(image_path).suffix.lower()
60
+ mime = {"jpg": "image/jpeg", ".jpeg": "image/jpeg",
61
+ ".png": "image/png", ".webp": "image/webp"}.get(suffix, "image/jpeg")
62
+
63
+ messages = [
64
+ {
65
+ "role": "user",
66
+ "content": [
67
+ {"type": "image", "image": f"data:{mime};base64,{img_b64}"},
68
+ {"type": "text", "text": OCR_PROMPT},
69
+ ],
70
+ }
71
+ ]
72
+
73
+ text_input = processor.apply_chat_template(
74
+ messages, tokenize=False, add_generation_prompt=True
75
+ )
76
+ inputs = processor(text=[text_input], return_tensors="pt").to(model.device)
77
+ output_ids = model.generate(**inputs, max_new_tokens=1024)
78
+ generated = output_ids[:, inputs["input_ids"].shape[1]:]
79
+ raw_text = processor.batch_decode(generated, skip_special_tokens=True)[0]
80
+
81
+ logger.info(f"OCR completed: {len(raw_text)} chars extracted")
82
+ return {"raw_text": raw_text, "source_image": image_path}
83
+
84
+ except Exception as e:
85
+ logger.error(f"Vision OCR failed: {e}")
86
+ return {"raw_text": "", "error": str(e)}
archive/legacy/orchestrator.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Orchestrator node.
3
+
4
+ Responsibilities:
5
+ 1. Classify the intent of the (already English) input
6
+ 2. Load relevant policy rules from SQLite
7
+ 3. Resolve any product aliases via ChromaDB fuzzy match
8
+ 4. Inject both into state before routing
9
+
10
+ Uses Llama-3.2-3B-Instruct via llama.cpp for fast intent classification.
11
+ Structured output enforced via JSON mode.
12
+ """
13
+
14
+ import json
15
+ import logging
16
+ from state import AgentState
17
+ from db.database import get_policies
18
+ from db.vector_store import resolve_aliases
19
+ from models.llm import call_llm
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ ORCHESTRATOR_SYSTEM = """You are an inventory management orchestrator for a small
24
+ Indian convenience store in Hyderabad. Your job is to classify the user's intent
25
+ and extract key entities. Always respond with valid JSON only, no markdown.
26
+
27
+ Valid intents:
28
+ - receipt_parse : user uploaded or described a supplier receipt
29
+ - reorder_trigger : user says something is out of stock or wants to reorder
30
+ - sales_log : user is recording what was sold today
31
+ - stock_query : user wants to know current stock levels
32
+ - report : user wants a weekly/monthly report or shrinkage analysis
33
+
34
+ Respond with:
35
+ {
36
+ "intent": "<one of the above>",
37
+ "entities": {
38
+ "products": ["<product name>", ...],
39
+ "supplier": "<supplier name or null>",
40
+ "quantities": {"<product>": <number>, ...}
41
+ },
42
+ "confidence": 0.0–1.0
43
+ }"""
44
+
45
+
46
+ def orchestrator_node(state: AgentState) -> AgentState:
47
+ trace = state.get("trace", [])
48
+ input_text = state.get("input", "")
49
+
50
+ # 1. Classify intent via LLM
51
+ raw = call_llm(
52
+ model="llama-3.2-3b",
53
+ system=ORCHESTRATOR_SYSTEM,
54
+ user=input_text,
55
+ max_tokens=256,
56
+ json_mode=True,
57
+ )
58
+
59
+ try:
60
+ parsed = json.loads(raw)
61
+ intent = parsed.get("intent", "stock_query")
62
+ entities = parsed.get("entities", {})
63
+ except (json.JSONDecodeError, AttributeError):
64
+ logger.warning("Orchestrator JSON parse failed, defaulting to stock_query")
65
+ intent = "stock_query"
66
+ entities = {}
67
+
68
+ trace.append(f"orchestrator: intent={intent} confidence={parsed.get('confidence', '?')}")
69
+
70
+ # 2. Load policies from SQLite
71
+ policies = get_policies()
72
+ trace.append(f"orchestrator: loaded {len(policies)} policy rules")
73
+
74
+ # 3. Resolve product aliases via ChromaDB
75
+ product_mentions = entities.get("products", [])
76
+ resolved = {}
77
+ if product_mentions:
78
+ resolved = resolve_aliases(product_mentions)
79
+ trace.append(f"orchestrator: alias resolved {resolved}")
80
+
81
+ return {
82
+ **state,
83
+ "intent": intent,
84
+ "active_policies": policies,
85
+ "resolved_aliases": resolved,
86
+ "trace": trace,
87
+ }
archive/legacy/po_check.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ po_check node — surfaces the PO draft and saves it as pending in SQLite.
3
+ Actual approval/rejection happens via Gradio UI buttons (approve_po / reject_po).
4
+ This node just persists the draft so the UI can display it.
5
+ """
6
+
7
+ from state import AgentState
8
+ from db.database import save_pending_po
9
+ from models.translate import en_to_te
10
+
11
+
12
+ def po_check_node(state: AgentState) -> AgentState:
13
+ trace = state.get("trace", [])
14
+ po_draft = state.get("po_draft")
15
+
16
+ if not po_draft:
17
+ trace.append("po_check: no PO draft, nothing to do")
18
+ return {**state, "trace": trace}
19
+
20
+ # Translate each item's reason into Telugu for the UI
21
+ for po in po_draft.get("purchase_orders", []):
22
+ for item in po.get("items", []):
23
+ reason_en = item.get("reason_en", "")
24
+ item["reason_te"] = en_to_te(reason_en) if reason_en else ""
25
+
26
+ # Persist as pending in SQLite (owner approves via UI)
27
+ po_ids = []
28
+ for po in po_draft.get("purchase_orders", []):
29
+ po_id = save_pending_po(po)
30
+ po_ids.append(po_id)
31
+ trace.append(
32
+ f"po_check: saved PO {po_id} for {po['supplier_name']} "
33
+ f"₹{po['po_total']:.0f} — awaiting owner approval"
34
+ )
35
+
36
+ response = (
37
+ f"{len(po_ids)} purchase order(s) ready for your approval. "
38
+ f"Check the approval panel to confirm or edit."
39
+ )
40
+
41
+ return {**state, "response": response, "trace": trace}
archive/legacy/receipt_parser.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Receipt parser node.
3
+
4
+ Input: state["input"] contains raw OCR text OR structured dict from vision model.
5
+ Output: state["structured_data"] = validated receipt dict ready for DB write.
6
+
7
+ Uses the LoRA-finetuned Mistral-7B (trained on Mahalakshmi / Sri Venkateshwara
8
+ receipt formats) for line-item extraction and schema validation.
9
+
10
+ Expected output schema:
11
+ {
12
+ "supplier": str,
13
+ "invoice_no": str,
14
+ "date": "YYYY-MM-DD",
15
+ "items": [
16
+ {
17
+ "product_raw": str, # as written on receipt
18
+ "product_id": str, # resolved canonical ID
19
+ "qty_cases": int,
20
+ "qty_units": int,
21
+ "unit_cost": float,
22
+ "total": float
23
+ }
24
+ ],
25
+ "subtotal": float,
26
+ "discount": float,
27
+ "gst": float,
28
+ "net_total": float
29
+ }
30
+ """
31
+
32
+ import json
33
+ import logging
34
+ from state import AgentState
35
+ from db.vector_store import resolve_aliases
36
+ from db.database import log_receipt
37
+ from models.llm import call_llm
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ PARSER_SYSTEM = """You are a receipt parser for an Indian convenience store.
42
+ Extract all line items from the receipt text below. Product names will be in
43
+ English. Quantities may be written as "4×870" or "4 cases" or "30X28=840".
44
+ Prices are in Indian Rupees (₹). Dates are in DD/MM/YY format — convert to YYYY-MM-DD.
45
+
46
+ Return ONLY valid JSON matching this exact schema:
47
+ {
48
+ "supplier": "string",
49
+ "invoice_no": "string or null",
50
+ "date": "YYYY-MM-DD",
51
+ "items": [
52
+ {
53
+ "product_raw": "string",
54
+ "qty_cases": integer,
55
+ "qty_units": integer,
56
+ "unit_cost": number,
57
+ "total": number
58
+ }
59
+ ],
60
+ "subtotal": number,
61
+ "discount": number,
62
+ "gst": number,
63
+ "net_total": number
64
+ }
65
+
66
+ If a field is missing, use null for strings and 0 for numbers. Do not add markdown."""
67
+
68
+
69
+ def receipt_parser_node(state: AgentState) -> AgentState:
70
+ trace = state.get("trace", [])
71
+ input_text = state.get("input", "")
72
+
73
+ # If vision model already returned structured dict, use it directly
74
+ if isinstance(state.get("structured_data"), dict):
75
+ raw_receipt = state["structured_data"]
76
+ trace.append("receipt_parser: using pre-parsed vision output")
77
+ else:
78
+ # Call finetuned Mistral-7B for text-based extraction
79
+ raw = call_llm(
80
+ model="mistral-7b-receipt", # points to LoRA-merged GGUF
81
+ system=PARSER_SYSTEM,
82
+ user=input_text,
83
+ max_tokens=1024,
84
+ json_mode=True,
85
+ )
86
+ try:
87
+ raw_receipt = json.loads(raw)
88
+ except json.JSONDecodeError:
89
+ logger.warning("Receipt parser JSON decode failed")
90
+ raw_receipt = {"items": [], "net_total": 0}
91
+
92
+ trace.append(f"receipt_parser: found {len(raw_receipt.get('items', []))} line items")
93
+
94
+ # Resolve product aliases for each raw product name
95
+ raw_names = [i.get("product_raw", "") for i in raw_receipt.get("items", [])]
96
+ alias_map = resolve_aliases(raw_names) if raw_names else {}
97
+
98
+ for item in raw_receipt.get("items", []):
99
+ item["product_id"] = alias_map.get(item.get("product_raw", ""), None)
100
+
101
+ unresolved = [i["product_raw"] for i in raw_receipt["items"] if not i.get("product_id")]
102
+ if unresolved:
103
+ trace.append(f"receipt_parser: unresolved aliases — {unresolved} (needs owner mapping)")
104
+
105
+ # Persist to DB
106
+ doc_id = log_receipt(raw_receipt)
107
+ trace.append(f"receipt_parser: logged as receipt doc_id={doc_id}")
108
+
109
+ return {
110
+ **state,
111
+ "structured_data": raw_receipt,
112
+ "trace": trace,
113
+ }
archive/legacy/reorder_agent.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reorder agent node.
3
+
4
+ Takes the list of products below threshold, groups them by supplier,
5
+ applies historical order quantity patterns from ChromaDB,
6
+ and builds a draft PO for each supplier.
7
+
8
+ Does NOT place orders — only produces po_draft for HITL approval.
9
+ """
10
+
11
+ import json
12
+ from state import AgentState
13
+ from db.database import get_product, get_supplier, get_pending_pos
14
+ from db.vector_store import get_order_pattern
15
+ from models.llm import call_llm
16
+
17
+ REORDER_SYSTEM = """You are a purchase order assistant for a small convenience store
18
+ in Hyderabad, India. Given a list of low-stock products with their supplier and
19
+ historical order quantities, generate a grouped purchase order suggestion.
20
+
21
+ Apply these rules:
22
+ 1. Group all items from the same supplier into one PO
23
+ 2. Suggest quantity = max(reorder_threshold × 2, last_order_qty)
24
+ 3. Explain the suggestion reason in simple English (will be translated to Telugu)
25
+ 4. Check minimum order value policy per supplier
26
+
27
+ Return ONLY valid JSON:
28
+ {
29
+ "purchase_orders": [
30
+ {
31
+ "supplier_id": "str",
32
+ "supplier_name": "str",
33
+ "items": [
34
+ {
35
+ "product_id": "str",
36
+ "product_name": "str",
37
+ "suggested_qty_cases": int,
38
+ "unit_cost": float,
39
+ "total": float,
40
+ "reason_en": "str"
41
+ }
42
+ ],
43
+ "po_total": float,
44
+ "meets_min_order": bool
45
+ }
46
+ ]
47
+ }"""
48
+
49
+
50
+ def reorder_agent_node(state: AgentState) -> AgentState:
51
+ trace = state.get("trace", [])
52
+ policies = state.get("active_policies", {})
53
+
54
+ below = (state.get("structured_data") or {}).get("below_threshold", [])
55
+ if not below:
56
+ trace.append("reorder_agent: no products below threshold, skipping")
57
+ return {**state, "trace": trace}
58
+
59
+ # Enrich each product with supplier info and order history
60
+ enriched = []
61
+ for product in below:
62
+ pid = product.get("product_id") or product.get("id")
63
+ p = get_product(pid) or product
64
+ supplier = get_supplier(p.get("supplier_id", ""))
65
+ pattern = get_order_pattern(pid) # from ChromaDB historical patterns
66
+
67
+ enriched.append({
68
+ "product_id": pid,
69
+ "product_name": p.get("name", pid),
70
+ "supplier_id": p.get("supplier_id", "unknown"),
71
+ "supplier_name": supplier.get("name", "Unknown") if supplier else "Unknown",
72
+ "current_stock": product.get("current_stock", 0),
73
+ "reorder_threshold": p.get("reorder_threshold", 2),
74
+ "last_order_qty_cases": pattern.get("avg_qty_cases", 2),
75
+ "unit_cost": p.get("last_unit_cost", 0),
76
+ "min_order_value": policies.get(
77
+ "min_order_per_supplier", {}
78
+ ).get(supplier.get("name", ""), 0) if supplier else 0,
79
+ })
80
+
81
+ trace.append(f"reorder_agent: enriched {len(enriched)} products for PO generation")
82
+
83
+ # Call LLM to generate grouped PO suggestions
84
+ raw = call_llm(
85
+ model="mistral-7b",
86
+ system=REORDER_SYSTEM,
87
+ user=json.dumps(enriched, ensure_ascii=False),
88
+ max_tokens=1024,
89
+ json_mode=True,
90
+ )
91
+
92
+ try:
93
+ result = json.loads(raw)
94
+ pos = result.get("purchase_orders", [])
95
+ except json.JSONDecodeError:
96
+ trace.append("reorder_agent: LLM JSON parse failed, building simple PO")
97
+ pos = _fallback_po(enriched)
98
+
99
+ # Flag POs that don't meet minimum order value
100
+ for po in pos:
101
+ if not po.get("meets_min_order", True):
102
+ trace.append(
103
+ f"reorder_agent: WARNING {po['supplier_name']} PO ₹{po['po_total']:.0f} "
104
+ f"below min order — flagged"
105
+ )
106
+
107
+ trace.append(f"reorder_agent: drafted {len(pos)} purchase orders for approval")
108
+
109
+ # Flatten into single po_draft structure
110
+ po_draft = {
111
+ "purchase_orders": pos,
112
+ "status": "pending_approval",
113
+ }
114
+
115
+ return {**state, "po_draft": po_draft, "trace": trace}
116
+
117
+
118
+ def _fallback_po(enriched: list) -> list:
119
+ """Simple supplier-grouped PO without LLM, used as fallback."""
120
+ by_supplier: dict = {}
121
+ for item in enriched:
122
+ sid = item["supplier_id"]
123
+ if sid not in by_supplier:
124
+ by_supplier[sid] = {
125
+ "supplier_id": sid,
126
+ "supplier_name": item["supplier_name"],
127
+ "items": [],
128
+ "po_total": 0.0,
129
+ "meets_min_order": True,
130
+ }
131
+ qty = max(item["reorder_threshold"] * 2, item["last_order_qty_cases"])
132
+ total = qty * item["unit_cost"]
133
+ by_supplier[sid]["items"].append({
134
+ "product_id": item["product_id"],
135
+ "product_name": item["product_name"],
136
+ "suggested_qty_cases": qty,
137
+ "unit_cost": item["unit_cost"],
138
+ "total": total,
139
+ "reason_en": f"Stock at {item['current_stock']}, threshold {item['reorder_threshold']}",
140
+ })
141
+ by_supplier[sid]["po_total"] += total
142
+
143
+ return list(by_supplier.values())
archive/legacy/reporting_agent.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Reporting agent node — weekly/monthly summaries and shrinkage reports.
3
+ Uses RAG: retrieves relevant stock_ledger records, then generates summary.
4
+ """
5
+
6
+ from state import AgentState
7
+ from db.database import (
8
+ get_weekly_summary,
9
+ get_shrinkage_report,
10
+ get_cost_vs_revenue,
11
+ )
12
+ from models.llm import call_llm
13
+
14
+ REPORT_SYSTEM = """You are a reporting assistant for a small Indian convenience store.
15
+ Summarize the provided inventory data clearly and concisely. Use simple English.
16
+ Include: total stock purchased (₹), estimated revenue, shrinkage %, and top 3 alerts.
17
+ Keep the response under 150 words. Do not use markdown headers."""
18
+
19
+
20
+ def reporting_agent_node(state: AgentState) -> AgentState:
21
+ trace = state.get("trace", [])
22
+ input_text = state.get("input", "").lower()
23
+
24
+ # Determine report type from input
25
+ if "shrinkage" in input_text or "తేడా" in input_text:
26
+ data = get_shrinkage_report()
27
+ report_type = "shrinkage"
28
+ elif "weekly" in input_text or "week" in input_text or "వారం" in input_text:
29
+ data = get_weekly_summary()
30
+ report_type = "weekly"
31
+ else:
32
+ data = get_cost_vs_revenue()
33
+ report_type = "cost_revenue"
34
+
35
+ trace.append(f"reporting_agent: generating {report_type} report")
36
+
37
+ summary = call_llm(
38
+ model="mistral-7b",
39
+ system=REPORT_SYSTEM,
40
+ user=str(data),
41
+ max_tokens=300,
42
+ json_mode=False,
43
+ )
44
+
45
+ trace.append("reporting_agent: report generated")
46
+
47
+ return {**state, "response": summary, "trace": trace}
archive/legacy/state.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared state object threaded through every LangGraph node.
3
+ All fields are Optional so nodes only populate what they produce.
4
+ """
5
+
6
+ from typing import Any, Optional
7
+ from typing_extensions import TypedDict
8
+
9
+
10
+ class AgentState(TypedDict, total=False):
11
+ # Raw input from voice / receipt / text
12
+ input: str
13
+ source: str # "voice" | "receipt" | "query"
14
+
15
+ # Intent classified by orchestrator
16
+ intent: Optional[str] # "receipt_parse" | "reorder_trigger" |
17
+ # "sales_log" | "stock_query" | "report"
18
+
19
+ # Structured data produced by receipt parser or inventory manager
20
+ structured_data: Optional[dict]
21
+
22
+ # Draft purchase order waiting for HITL approval
23
+ po_draft: Optional[dict]
24
+
25
+ # Final natural-language response (English; translated to Telugu in UI)
26
+ response: Optional[str]
27
+
28
+ # Step-by-step trace accumulated across nodes
29
+ trace: list[str]
30
+
31
+ # Policy rules injected by orchestrator before routing
32
+ active_policies: Optional[dict]
33
+
34
+ # Alias resolution result from vector DB lookup
35
+ resolved_aliases: Optional[dict]
archive/legacy/translate.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Telugu ↔ English translation using IndicTrans2 (ai4bharat).
3
+ Runs fully locally — no API calls.
4
+
5
+ Model: ai4bharat/indictrans2-indic-en-1B (~1GB, CPU-friendly)
6
+ ai4bharat/indictrans2-en-indic-1B for English → Telugu
7
+ """
8
+
9
+ import logging
10
+ logger = logging.getLogger(__name__)
11
+
12
+ _te_en_model = None
13
+ _te_en_tokenizer = None
14
+ _en_te_model = None
15
+ _en_te_tokenizer = None
16
+
17
+
18
+ def _load_te_en():
19
+ global _te_en_model, _te_en_tokenizer
20
+ if _te_en_model is None:
21
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
22
+ name = "ai4bharat/indictrans2-indic-en-1B"
23
+ _te_en_tokenizer = AutoTokenizer.from_pretrained(name, trust_remote_code=True)
24
+ _te_en_model = AutoModelForSeq2SeqLM.from_pretrained(name, trust_remote_code=True)
25
+ logger.info("IndicTrans2 te→en loaded")
26
+ return _te_en_tokenizer, _te_en_model
27
+
28
+
29
+ def _load_en_te():
30
+ global _en_te_model, _en_te_tokenizer
31
+ if _en_te_model is None:
32
+ from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
33
+ name = "ai4bharat/indictrans2-en-indic-1B"
34
+ _en_te_tokenizer = AutoTokenizer.from_pretrained(name, trust_remote_code=True)
35
+ _en_te_model = AutoModelForSeq2SeqLM.from_pretrained(name, trust_remote_code=True)
36
+ logger.info("IndicTrans2 en→te loaded")
37
+ return _en_te_tokenizer, _en_te_model
38
+
39
+
40
+ def te_to_en(text: str) -> str:
41
+ """Translate Telugu → English. Product names in English stay unchanged."""
42
+ if not text or not text.strip():
43
+ return text
44
+ # If text has no Telugu chars, return as-is
45
+ if not any("\u0C00" <= c <= "\u0C7F" for c in text):
46
+ return text
47
+ try:
48
+ tokenizer, model = _load_te_en()
49
+ # IndicTrans2 expects source language tag
50
+ tagged = f"<2en> {text}"
51
+ inputs = tokenizer(tagged, return_tensors="pt", padding=True)
52
+ outputs = model.generate(**inputs, max_new_tokens=256, num_beams=4)
53
+ result = tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
54
+ logger.debug(f"te→en: '{text[:40]}' → '{result[:40]}'")
55
+ return result
56
+ except Exception as e:
57
+ logger.error(f"te_to_en failed: {e}")
58
+ return text
59
+
60
+
61
+ def en_to_te(text: str) -> str:
62
+ """Translate English → Telugu. Numbers and product names kept in English."""
63
+ if not text or not text.strip():
64
+ return text
65
+ try:
66
+ tokenizer, model = _load_en_te()
67
+ tagged = f"<2te> {text}"
68
+ inputs = tokenizer(tagged, return_tensors="pt", padding=True)
69
+ outputs = model.generate(**inputs, max_new_tokens=256, num_beams=4)
70
+ result = tokenizer.decode(outputs[0], skip_special_tokens=True).strip()
71
+ logger.debug(f"en→te: '{text[:40]}' → '{result[:40]}'")
72
+ return result
73
+ except Exception as e:
74
+ logger.error(f"en_to_te failed: {e}")
75
+ return text
archive/legacy/vector_store.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ChromaDB vector store.
3
+
4
+ Collections:
5
+ product_aliases — embed product names/aliases; query to resolve fuzzy OCR text
6
+ receipt_chunks — embed raw receipt text for price history RAG
7
+ order_patterns — embed (product_id, month) → typical order qty
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ CHROMA_PATH = Path(__file__).parent.parent / "chroma_db"
17
+
18
+ _client = None
19
+ _alias_col = None
20
+ _pattern_col = None
21
+
22
+
23
+ def _get_client():
24
+ global _client
25
+ if _client is None:
26
+ import chromadb
27
+ _client = chromadb.PersistentClient(path=str(CHROMA_PATH))
28
+ return _client
29
+
30
+
31
+ def _get_alias_collection():
32
+ global _alias_col
33
+ if _alias_col is None:
34
+ client = _get_client()
35
+ _alias_col = client.get_or_create_collection(
36
+ name="product_aliases",
37
+ metadata={"hnsw:space": "cosine"},
38
+ )
39
+ _seed_aliases()
40
+ return _alias_col
41
+
42
+
43
+ def _seed_aliases():
44
+ """Seed alias collection from SQLite aliases table on first run."""
45
+ col = _alias_col
46
+ if col.count() > 0:
47
+ return
48
+ try:
49
+ from db.database import _conn
50
+ with _conn() as conn:
51
+ rows = conn.execute("SELECT alias, product_id FROM aliases").fetchall()
52
+ if rows:
53
+ col.upsert(
54
+ documents=[r["alias"] for r in rows],
55
+ ids=[f"alias_{i}" for i in range(len(rows))],
56
+ metadatas=[{"product_id": r["product_id"]} for r in rows],
57
+ )
58
+ logger.info(f"Seeded {len(rows)} aliases into ChromaDB")
59
+ except Exception as e:
60
+ logger.warning(f"Alias seed failed: {e}")
61
+
62
+
63
+ def resolve_aliases(raw_names: list[str], threshold: float = 0.75) -> dict:
64
+ """
65
+ Given a list of raw product name strings (from OCR or voice),
66
+ return a dict mapping each raw name → canonical product_id.
67
+
68
+ Uses cosine similarity; returns None for names below threshold.
69
+ """
70
+ if not raw_names:
71
+ return {}
72
+
73
+ col = _get_alias_collection()
74
+ result = {}
75
+
76
+ for name in raw_names:
77
+ if not name.strip():
78
+ continue
79
+ try:
80
+ query_result = col.query(
81
+ query_texts=[name],
82
+ n_results=1,
83
+ include=["metadatas", "distances"],
84
+ )
85
+ distance = query_result["distances"][0][0] if query_result["distances"] else 1.0
86
+ similarity = 1 - distance # cosine distance → similarity
87
+
88
+ if similarity >= threshold:
89
+ product_id = query_result["metadatas"][0][0].get("product_id")
90
+ result[name] = product_id
91
+ logger.debug(f"Alias resolved: '{name}' → {product_id} (sim={similarity:.2f})")
92
+ else:
93
+ result[name] = None
94
+ logger.debug(f"No alias match for '{name}' (best sim={similarity:.2f})")
95
+ except Exception as e:
96
+ logger.warning(f"Alias lookup failed for '{name}': {e}")
97
+ result[name] = None
98
+
99
+ return result
100
+
101
+
102
+ def upsert_alias(alias: str, product_id: str):
103
+ """Add a new alias mapping (called when owner confirms an unresolved name)."""
104
+ col = _get_alias_collection()
105
+ col.upsert(
106
+ documents=[alias],
107
+ ids=[f"alias_{alias.lower().replace(' ', '_')}"],
108
+ metadatas=[{"product_id": product_id}],
109
+ )
110
+
111
+
112
+ def get_order_pattern(product_id: str) -> dict:
113
+ """
114
+ Retrieve historical order pattern for a product.
115
+ Returns avg_qty_cases and seasonality hints.
116
+ Falls back to default if no history found.
117
+ """
118
+ try:
119
+ client = _get_client()
120
+ col = client.get_or_create_collection("order_patterns")
121
+ result = col.query(
122
+ query_texts=[product_id],
123
+ n_results=3,
124
+ include=["metadatas", "documents"],
125
+ )
126
+ if result["metadatas"] and result["metadatas"][0]:
127
+ meta = result["metadatas"][0][0]
128
+ return {
129
+ "avg_qty_cases": meta.get("avg_qty_cases", 2),
130
+ "last_qty_cases": meta.get("last_qty_cases", 2),
131
+ }
132
+ except Exception as e:
133
+ logger.warning(f"Order pattern lookup failed for {product_id}: {e}")
134
+
135
+ return {"avg_qty_cases": 2, "last_qty_cases": 2}
136
+
137
+
138
+ def store_order_pattern(product_id: str, qty_cases: int):
139
+ """Update order pattern after a PO is approved."""
140
+ try:
141
+ client = _get_client()
142
+ col = client.get_or_create_collection("order_patterns")
143
+ from datetime import datetime
144
+ col.upsert(
145
+ documents=[f"{product_id} ordered {qty_cases} cases"],
146
+ ids=[f"pattern_{product_id}_{datetime.now().strftime('%Y%m')}"],
147
+ metadatas=[{
148
+ "product_id": product_id,
149
+ "avg_qty_cases": qty_cases,
150
+ "last_qty_cases": qty_cases,
151
+ "month": datetime.now().strftime("%Y-%m"),
152
+ }],
153
+ )
154
+ except Exception as e:
155
+ logger.warning(f"Failed to store order pattern: {e}")
156
+
157
+
158
+ def embed_receipt_chunk(text: str, metadata: dict):
159
+ """Store a receipt text chunk for price history RAG."""
160
+ try:
161
+ client = _get_client()
162
+ col = client.get_or_create_collection("receipt_chunks")
163
+ import hashlib
164
+ chunk_id = hashlib.md5(text.encode()).hexdigest()
165
+ col.upsert(documents=[text], ids=[chunk_id], metadatas=[metadata])
166
+ except Exception as e:
167
+ logger.warning(f"Failed to embed receipt chunk: {e}")
benchmarks/benchmark_receipt_models.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import time
6
+ from pathlib import Path
7
+
8
+ import requests
9
+
10
+ from dukaan_saathi.parsers.receipt_text import parse_receipt_text
11
+ from dukaan_saathi.storage import init_db
12
+
13
+
14
+ RECEIPT_IMAGES = [
15
+ Path("samples/receipts/printed_out_receipt.jpeg"),
16
+ Path("samples/receipts/receipt.jpeg"),
17
+ Path("samples/receipts/tally.jpeg"),
18
+ ]
19
+
20
+
21
+ def post_image(endpoint: str, image_path: Path) -> dict:
22
+ start = time.perf_counter()
23
+
24
+ with image_path.open("rb") as f:
25
+ response = requests.post(
26
+ endpoint,
27
+ files={"image": (image_path.name, f, "image/jpeg")},
28
+ timeout=240,
29
+ )
30
+
31
+ elapsed = time.perf_counter() - start
32
+ response.raise_for_status()
33
+
34
+ payload = response.json()
35
+ payload["_client_latency_seconds"] = round(elapsed, 2)
36
+ return payload
37
+
38
+
39
+ def score_payload(payload: dict) -> dict:
40
+ raw_text = payload.get("raw_text") or payload.get("text") or ""
41
+
42
+ rows, trace = parse_receipt_text(raw_text)
43
+
44
+ matched = [row for row in rows if row.get("matched_product_id")]
45
+ needs_review = [row for row in rows if not row.get("matched_product_id")]
46
+
47
+ return {
48
+ "model": payload.get("model", "unknown"),
49
+ "raw_text_chars": len(raw_text),
50
+ "parsed_row_count": len(rows),
51
+ "matched_row_count": len(matched),
52
+ "needs_review_count": len(needs_review),
53
+ "client_latency_seconds": payload.get("_client_latency_seconds"),
54
+ "server_latency_seconds": payload.get("latency_seconds"),
55
+ "trace": trace,
56
+ "raw_text": raw_text,
57
+ "rows": rows,
58
+ }
59
+
60
+
61
+ def main() -> None:
62
+ init_db()
63
+
64
+ endpoints = {
65
+ "minicpm": os.getenv("MINICPM_RECEIPT_ENDPOINT", "").strip(),
66
+ "molmo": os.getenv("MOLMO_RECEIPT_ENDPOINT", "").strip(),
67
+ }
68
+
69
+ endpoints = {name: url for name, url in endpoints.items() if url}
70
+ if not endpoints:
71
+ raise SystemExit(
72
+ "Set at least one endpoint: MINICPM_RECEIPT_ENDPOINT or MOLMO_RECEIPT_ENDPOINT"
73
+ )
74
+
75
+ results = []
76
+
77
+ for model_name, endpoint in endpoints.items():
78
+ for image_path in RECEIPT_IMAGES:
79
+ print(f"\n=== {model_name} :: {image_path.name} ===")
80
+
81
+ try:
82
+ payload = post_image(endpoint, image_path)
83
+ score = score_payload(payload)
84
+ score["endpoint_name"] = model_name
85
+ score["image"] = image_path.name
86
+ results.append(score)
87
+
88
+ print(
89
+ json.dumps(
90
+ {
91
+ "model": score["model"],
92
+ "image": score["image"],
93
+ "raw_text_chars": score["raw_text_chars"],
94
+ "parsed_row_count": score["parsed_row_count"],
95
+ "matched_row_count": score["matched_row_count"],
96
+ "needs_review_count": score["needs_review_count"],
97
+ "client_latency_seconds": score["client_latency_seconds"],
98
+ "server_latency_seconds": score["server_latency_seconds"],
99
+ },
100
+ indent=2,
101
+ )
102
+ )
103
+
104
+ except Exception as exc:
105
+ print(f"FAILED: {exc}")
106
+ results.append(
107
+ {
108
+ "endpoint_name": model_name,
109
+ "image": image_path.name,
110
+ "error": str(exc),
111
+ }
112
+ )
113
+
114
+ out_dir = Path("benchmarks")
115
+ out_dir.mkdir(exist_ok=True)
116
+
117
+ out_path = out_dir / "receipt_model_benchmark.json"
118
+ out_path.write_text(json.dumps(results, indent=2, ensure_ascii=False))
119
+
120
+ print(f"\nWrote {out_path}")
121
+
122
+
123
+ if __name__ == "__main__":
124
+ main()
data/.gitkeep ADDED
File without changes
data/finetune/receipt_examples.jsonl ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ {"input": "MAHALAKSHMI MARKETING\nNo. 2816 Date: 27/5/26\nM/s. Veerabala (Mulal)\nParle 1 X 2450 = 2450\nBingo(C) 4 X 870 = 3480\nSubtotal 5930\nDiscount 612\nTotal 6542", "output": "{\"supplier\": \"Mahalakshmi Marketing\", \"invoice_no\": \"2816\", \"date\": \"2026-05-27\", \"items\": [{\"product_raw\": \"Parle\", \"qty_cases\": 1, \"qty_units\": 1, \"unit_cost\": 2450.0, \"total\": 2450.0}, {\"product_raw\": \"Bingo(C)\", \"qty_cases\": 4, \"qty_units\": 4, \"unit_cost\": 870.0, \"total\": 3480.0}], \"subtotal\": 5930.0, \"discount\": 612.0, \"gst\": 0.0, \"net_total\": 6542.0}"}
2
+ {"input": "SRI VENKATESHWARA MARKETING\nGSTIN: 36AZLIPV6442K12M\nCUSTOMER: VEERA BHADRA WS\nBill Date: 28/05/2026\n1 PARLE-G 100G QTY: 5/0 MRP: 10 SALE RATE: 8.625\n2 HAPPY 2 (24P)*13 QTY: 10/0 MRP: 9 SALE RATE: 4.464\nGROSS SALES: 8569.032\nSCHEMES: 168.352\nCASH DISC: 420.034\nGST: 210.017 SGST: 210.017\nNET AMOUNT: 8821.00", "output": "{\"supplier\": \"Sri Venkateshwara Marketing\", \"invoice_no\": \"SVM/26-27/2598\", \"date\": \"2026-05-28\", \"items\": [{\"product_raw\": \"PARLE-G 100G\", \"qty_cases\": 5, \"qty_units\": 120, \"unit_cost\": 8.625, \"total\": 1035.0}, {\"product_raw\": \"HAPPY 2 (24P)\", \"qty_cases\": 10, \"qty_units\": 240, \"unit_cost\": 4.464, \"total\": 1071.36}], \"subtotal\": 8569.032, \"discount\": 588.386, \"gst\": 420.034, \"net_total\": 8821.0}"}
3
+ {"input": "Brundhna Boys - 28/05\nhne 30X28 = 840\noam 50X9.5 450\nBm 10X9.5 95\nBm 5X12 50\nTotal 1435", "output": "{\"supplier\": \"sales_note\", \"invoice_no\": null, \"date\": \"2026-05-28\", \"items\": [{\"product_raw\": \"hne\", \"qty_cases\": 0, \"qty_units\": 30, \"unit_cost\": 28.0, \"total\": 840.0}, {\"product_raw\": \"oam\", \"qty_cases\": 0, \"qty_units\": 50, \"unit_cost\": 9.5, \"total\": 450.0}, {\"product_raw\": \"Bm\", \"qty_cases\": 0, \"qty_units\": 10, \"unit_cost\": 9.5, \"total\": 95.0}, {\"product_raw\": \"Bm\", \"qty_cases\": 0, \"qty_units\": 5, \"unit_cost\": 12.0, \"total\": 50.0}], \"subtotal\": 1435.0, \"discount\": 0.0, \"gst\": 0.0, \"net_total\": 1435.0}"}
4
+ {"input": "Mahalakshmi Marketing\nDocument Type: handwritten supplier bill\nBill No: 2816\nDate: 27/05/2026\n\nParle bulk 1 X 2450 = 2450\nBingo(C) 4 X 870 = 3480\nUnknown handwritten item 1 X 612 = 612\n\nTotal: 6542", "output": "{\"supplier\": \"Mahalakshmi Marketing\", \"invoice_no\": \"2816\", \"date\": \"2026-05-27\", \"items\": [{\"product_raw\": \"Parle bulk\", \"qty_cases\": 1, \"qty_units\": 1, \"unit_cost\": 2450.0, \"total\": 2450.0}, {\"product_raw\": \"Bingo(C)\", \"qty_cases\": 4, \"qty_units\": 4, \"unit_cost\": 870.0, \"total\": 3480.0}, {\"product_raw\": \"Unknown handwritten item\", \"qty_cases\": 0, \"qty_units\": 1, \"unit_cost\": 612.0, \"total\": 612.0, \"needs_review\": true}], \"subtotal\": 6542.0, \"discount\": 0.0, \"gst\": 0.0, \"net_total\": 6542.0}"}
5
+ {"input": "Brundavan Buns\nDocument Type: handwritten tally note\nDate: 28/05\n\nItem one 30 X 28 = 840\nOBM 50 X 9.5 = 475\nBun 10 X 9.5 = 95\nBun 5 X 10 = 50\n\nTotal: 1435", "output": "{\"supplier\": \"Brundavan Buns\", \"invoice_no\": null, \"date\": \"2026-05-28\", \"items\": [{\"product_raw\": \"Item one\", \"qty_cases\": 0, \"qty_units\": 30, \"unit_cost\": 28.0, \"total\": 840.0}, {\"product_raw\": \"OBM\", \"qty_cases\": 0, \"qty_units\": 50, \"unit_cost\": 9.5, \"total\": 475.0}, {\"product_raw\": \"Bun\", \"qty_cases\": 0, \"qty_units\": 10, \"unit_cost\": 9.5, \"total\": 95.0}, {\"product_raw\": \"Bun\", \"qty_cases\": 0, \"qty_units\": 5, \"unit_cost\": 10.0, \"total\": 50.0}], \"subtotal\": 1460.0, \"discount\": 25.0, \"gst\": 0.0, \"net_total\": 1435.0}"}
6
+ {"input": "Sri Venkateshwara Marketing\nDocument Type: printed tax invoice\nInvoice No: 6\nDate: 28/05/2026\n\nPARLE-G 60GM RS.72P | 5/0 | MRP 10.00 | RATE 8.625 | GST 5% | NET 3105.000\nHAPPY HAPPY 27.5G(24P)*13 | 10/0 | MRP 5.00 | RATE 4.464 | GST 5% | NET 5715.710\n\nGross Sales: 8759.032\nCGST: 210.017\nSGST: 210.017\nNet Amount: 8821.00", "output": "{\"supplier\": \"Sri Venkateshwara Marketing\", \"invoice_no\": \"6\", \"date\": \"2026-05-28\", \"items\": [{\"product_raw\": \"PARLE-G 60GM RS.72P\", \"qty_cases\": 5, \"qty_units\": 0, \"unit_cost\": 8.625, \"total\": 3105.0}, {\"product_raw\": \"HAPPY HAPPY 27.5G(24P)*13\", \"qty_cases\": 10, \"qty_units\": 0, \"unit_cost\": 4.464, \"total\": 5715.71}], \"subtotal\": 8759.032, \"discount\": 0.0, \"gst\": 420.034, \"net_total\": 8821.0}"}
7
+ {"input": "MAHALAKSHMI MARKETING\nNo. 3105 Date: 3/6/26\nM/s. Veerabhadra WS\nBingo(C) 3 X 870 = 2610\nParle bulk 2 X 2450 = 4900\nHappy Happy 1 X 960 = 960\nSubtotal 8470\nDiscount 847\nTotal 7623", "output": "{\"supplier\": \"Mahalakshmi Marketing\", \"invoice_no\": \"3105\", \"date\": \"2026-06-03\", \"items\": [{\"product_raw\": \"Bingo(C)\", \"qty_cases\": 3, \"qty_units\": 3, \"unit_cost\": 870.0, \"total\": 2610.0}, {\"product_raw\": \"Parle bulk\", \"qty_cases\": 2, \"qty_units\": 2, \"unit_cost\": 2450.0, \"total\": 4900.0}, {\"product_raw\": \"Happy Happy\", \"qty_cases\": 1, \"qty_units\": 1, \"unit_cost\": 960.0, \"total\": 960.0}], \"subtotal\": 8470.0, \"discount\": 847.0, \"gst\": 0.0, \"net_total\": 7623.0}"}
8
+ {"input": "VIKRAM AGENCIES\nInvoice: VA-2026-0445\nDate: 07-06-2026\nPARTY: VEERABHADRA STORES\n\n1 Parle-G 250g QTY 4 RATE 180 AMT 720\n2 Bourbon Biscuit QTY 6 RATE 105 AMT 630\n3 Monaco Salted QTY 3 RATE 220 AMT 660\n\nGross: 2010\nDisc: 201\nNet: 1809", "output": "{\"supplier\": \"Vikram Agencies\", \"invoice_no\": \"VA-2026-0445\", \"date\": \"2026-06-07\", \"items\": [{\"product_raw\": \"Parle-G 250g\", \"qty_cases\": 0, \"qty_units\": 4, \"unit_cost\": 180.0, \"total\": 720.0}, {\"product_raw\": \"Bourbon Biscuit\", \"qty_cases\": 0, \"qty_units\": 6, \"unit_cost\": 105.0, \"total\": 630.0}, {\"product_raw\": \"Monaco Salted\", \"qty_cases\": 0, \"qty_units\": 3, \"unit_cost\": 220.0, \"total\": 660.0}], \"subtotal\": 2010.0, \"discount\": 201.0, \"gst\": 0.0, \"net_total\": 1809.0}"}
9
+ {"input": "KRISHNA GENERAL STORES\nBill No: KGS/157\nDate: 08/06/2026\nCustomer: Veerabhadra\n\nParle Monaco 200g 5 pkt @75 375\nKrack Jack Biscuit 4 pkt @60 240\nHide & Seek Choco 3 pkt @95 285\nSunfeast YiPPee 6 pkt @40 240\n\nSub Total: 1140\nDisc @ 5%: 57\nNet Payable: 1083", "output": "{\"supplier\": \"Krishna General Stores\", \"invoice_no\": \"KGS/157\", \"date\": \"2026-06-08\", \"items\": [{\"product_raw\": \"Parle Monaco 200g\", \"qty_cases\": 0, \"qty_units\": 5, \"unit_cost\": 75.0, \"total\": 375.0}, {\"product_raw\": \"Krack Jack Biscuit\", \"qty_cases\": 0, \"qty_units\": 4, \"unit_cost\": 60.0, \"total\": 240.0}, {\"product_raw\": \"Hide & Seek Choco\", \"qty_cases\": 0, \"qty_units\": 3, \"unit_cost\": 95.0, \"total\": 285.0}, {\"product_raw\": \"Sunfeast YiPPee\", \"qty_cases\": 0, \"qty_units\": 6, \"unit_cost\": 40.0, \"total\": 240.0}], \"subtotal\": 1140.0, \"discount\": 57.0, \"gst\": 0.0, \"net_total\": 1083.0}"}
10
+ {"input": "Brundavan - 10/6\npav 40X8 = 320\nbrd 25X32 = 800\nbns 15X35 = 525\ncake slc 10X45 = 450\nTTl 2095", "output": "{\"supplier\": \"Brundavan Buns\", \"invoice_no\": null, \"date\": \"2026-06-10\", \"items\": [{\"product_raw\": \"pav\", \"qty_cases\": 0, \"qty_units\": 40, \"unit_cost\": 8.0, \"total\": 320.0}, {\"product_raw\": \"brd\", \"qty_cases\": 0, \"qty_units\": 25, \"unit_cost\": 32.0, \"total\": 800.0}, {\"product_raw\": \"bns\", \"qty_cases\": 0, \"qty_units\": 15, \"unit_cost\": 35.0, \"total\": 525.0}, {\"product_raw\": \"cake slc\", \"qty_cases\": 0, \"qty_units\": 10, \"unit_cost\": 45.0, \"total\": 450.0}], \"subtotal\": 2095.0, \"discount\": 0.0, \"gst\": 0.0, \"net_total\": 2095.0}"}
docs/deployment_setup.md ADDED
@@ -0,0 +1,269 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deployment Setup: Modal → HF Space
2
+
3
+ This guide walks through deploying the three Modal model services and wiring
4
+ their endpoint URLs into the public Hugging Face Space as secrets.
5
+
6
+ ---
7
+
8
+ ## What gets deployed
9
+
10
+ | Service | Model | Env var | App name |
11
+ |---------|-------|---------|----------|
12
+ | Receipt OCR | MiniCPM-V 4.6 | `MODAL_RECEIPT_ENDPOINT` | `dukaan-saathi-receipt-vlm` |
13
+ | Speech ASR | Distil-Whisper small | `MODAL_SPEECH_ENDPOINT` | `dukaan-saathi-speech-asr` |
14
+ | Voice NLU | Qwen2.5-1.5B-Instruct | `MODAL_NLU_ENDPOINT` | `dukaan-saathi-command-nlu` |
15
+
16
+ All three are optional — the app falls back to deterministic parsers when any
17
+ endpoint is missing. Deploy whichever you want active on the Space.
18
+
19
+ ---
20
+
21
+ ## Part 1 — Prerequisites
22
+
23
+ ### 1.1 Modal account and CLI
24
+
25
+ Create a free Modal account at https://modal.com if you do not have one.
26
+
27
+ Install the Modal CLI and log in:
28
+
29
+ ```bash
30
+ uv add modal # adds to this project's venv
31
+ uv run modal setup # opens a browser to authenticate
32
+ ```
33
+
34
+ After `modal setup` completes, verify you are logged in:
35
+
36
+ ```bash
37
+ uv run modal token show
38
+ ```
39
+
40
+ You should see your workspace name (e.g., `zappandy`).
41
+
42
+ ### 1.2 Hugging Face account
43
+
44
+ You need a Hugging Face account with write access to the Space at
45
+ `https://huggingface.co/spaces/Zappandy/Kirana_AI`. If this is your Space
46
+ you already have access.
47
+
48
+ ---
49
+
50
+ ## Part 2 — Deploy Modal services
51
+
52
+ Run each deploy command from the project root. Each command:
53
+
54
+ 1. Deploys (or re-deploys) the Modal app
55
+ 2. Fetches the generated endpoint URL from Modal
56
+ 3. Writes it to your local `.env` file
57
+
58
+ ### 2.1 Receipt image OCR (MiniCPM-V)
59
+
60
+ ```bash
61
+ scripts/modal_deploy.sh modal_apps/receipt_vlm_service.py
62
+ ```
63
+
64
+ When done, `.env` will contain:
65
+
66
+ ```text
67
+ MODAL_RECEIPT_ENDPOINT=https://<workspace>--dukaan-saathi-receipt-vlm-api.modal.run/extract
68
+ ```
69
+
70
+ Verify it is responding (replace with your actual URL from `.env`):
71
+
72
+ ```bash
73
+ source scripts/_env.sh
74
+ curl "${MODAL_RECEIPT_ENDPOINT%/extract}/health"
75
+ ```
76
+
77
+ Expected response:
78
+
79
+ ```json
80
+ {"status": "ok", "model": "openbmb/MiniCPM-V-2_6"}
81
+ ```
82
+
83
+ First call may take 30–60 seconds while the GPU container starts. Subsequent
84
+ calls within the `scaledown_window` are fast.
85
+
86
+ ### 2.2 Speech transcription (Distil-Whisper)
87
+
88
+ ```bash
89
+ scripts/modal_deploy.sh modal_apps/speech_asr_service.py
90
+ ```
91
+
92
+ When done, `.env` will contain:
93
+
94
+ ```text
95
+ MODAL_SPEECH_ENDPOINT=https://<workspace>--speech-transcribe.modal.run
96
+ ```
97
+
98
+ Verify:
99
+
100
+ ```bash
101
+ source scripts/_env.sh
102
+ SPEECH_HEALTH="${MODAL_SPEECH_ENDPOINT/speech-transcribe/speech-health}"
103
+ curl "$SPEECH_HEALTH"
104
+ ```
105
+
106
+ Expected:
107
+
108
+ ```json
109
+ {"status": "ok", "model": "distil-whisper/distil-small.en"}
110
+ ```
111
+
112
+ ### 2.3 Voice command NLU (Qwen2.5-1.5B-Instruct)
113
+
114
+ ```bash
115
+ scripts/modal_deploy.sh modal_apps/command_nlu_service.py
116
+ ```
117
+
118
+ When done, `.env` will contain:
119
+
120
+ ```text
121
+ MODAL_NLU_ENDPOINT=https://<workspace>--nlu-extract.modal.run
122
+ ```
123
+
124
+ Verify:
125
+
126
+ ```bash
127
+ source scripts/_env.sh
128
+ curl -s -X POST "$MODAL_NLU_ENDPOINT" \
129
+ -H "Content-Type: application/json" \
130
+ -d '{"command": "add Bun 12"}' | python3 -m json.tool
131
+ ```
132
+
133
+ Expected:
134
+
135
+ ```json
136
+ {
137
+ "intent": "add_stock",
138
+ "product_name": "Bun",
139
+ "quantity": 12,
140
+ "unit": null,
141
+ "confidence": "high",
142
+ "model": "Qwen/Qwen2.5-1.5B-Instruct"
143
+ }
144
+ ```
145
+
146
+ ---
147
+
148
+ ## Part 3 — Add secrets to the HF Space
149
+
150
+ The HF Space container does not read your local `.env` file. You must add each
151
+ endpoint URL as a Space secret through the Hugging Face web UI.
152
+
153
+ ### 3.1 Open Space settings
154
+
155
+ 1. Go to https://huggingface.co/spaces/Zappandy/Kirana_AI
156
+ 2. Click the **Settings** tab (top of the Space page)
157
+ 3. Scroll down to **Variables and secrets**
158
+
159
+ ### 3.2 Add each secret
160
+
161
+ Click **New secret** for each of the following. Use the exact variable names
162
+ below — the app reads these from the environment at runtime.
163
+
164
+ | Secret name | Value |
165
+ |-------------|-------|
166
+ | `MODAL_RECEIPT_ENDPOINT` | the URL written to `.env` in step 2.1 |
167
+ | `MODAL_SPEECH_ENDPOINT` | the URL written to `.env` in step 2.2 |
168
+ | `MODAL_NLU_ENDPOINT` | the URL written to `.env` in step 2.3 |
169
+ | `HF_TOKEN` | your HF write token (only needed if `HF_RECEIPT_MODEL_REPO` is private) |
170
+ | `HF_RECEIPT_MODEL_REPO` | e.g. `Zappandy/dukaan-saathi-receipt-lora` |
171
+
172
+ Secrets are encrypted and only visible to the Space runtime — not to other
173
+ users or in the Space logs.
174
+
175
+ **Do not add** `DB_PATH` unless you have enabled persistent storage on the
176
+ Space. Without persistent storage, leave it unset and the DB stays
177
+ runtime-local (resets on restart).
178
+
179
+ ### 3.3 Restart the Space
180
+
181
+ After adding secrets, click **Factory reset** or wait for the Space to rebuild
182
+ on its own. The new environment variables take effect on the next container
183
+ start.
184
+
185
+ To force an immediate rebuild, push any change to the Space remote:
186
+
187
+ ```bash
188
+ git checkout --orphan _hf_tmp
189
+ git add -A
190
+ git commit -m "trigger rebuild"
191
+ git push space HEAD:main --force
192
+ git checkout main
193
+ git branch -D _hf_tmp
194
+ ```
195
+
196
+ ---
197
+
198
+ ## Part 4 — Verify end-to-end on the Space
199
+
200
+ After the Space rebuilds:
201
+
202
+ 1. Open the Space URL and wait for the app to finish loading
203
+ 2. Go to **Voice** tab → type `add Bun 12` → click **Parse for approval**
204
+ - The agent reasoning panel should show NLU steps if `MODAL_NLU_ENDPOINT` is set
205
+ 3. Go to **Bill Desk** → upload a receipt photo
206
+ - Cold start message appears while MiniCPM-V loads (~30 s first time)
207
+ - Editable rows appear after extraction
208
+ 4. Go to **Voice** → click **Transcribe with Modal** and upload a `.wav` file
209
+ - Transcript fills in automatically
210
+
211
+ If any Modal service times out or returns an error, the app falls back to the
212
+ deterministic parser and shows a trace message explaining the fallback.
213
+
214
+ ---
215
+
216
+ ## Part 5 — Managing costs
217
+
218
+ Modal charges only for GPU time. Each service has a `scaledown_window=300` (5
219
+ minutes) — after 5 minutes of inactivity the container stops and you stop being
220
+ charged.
221
+
222
+ To stop all services immediately:
223
+
224
+ ```bash
225
+ uv run modal app stop dukaan-saathi-receipt-vlm || true
226
+ uv run modal app stop dukaan-saathi-speech-asr || true
227
+ uv run modal app stop dukaan-saathi-command-nlu || true
228
+ uv run modal app list
229
+ ```
230
+
231
+ Look for `Tasks 0` in the output to confirm containers are stopped.
232
+
233
+ To redeploy after stopping (same commands as Part 2):
234
+
235
+ ```bash
236
+ scripts/modal_deploy.sh modal_apps/receipt_vlm_service.py
237
+ scripts/modal_deploy.sh modal_apps/speech_asr_service.py
238
+ scripts/modal_deploy.sh modal_apps/command_nlu_service.py
239
+ ```
240
+
241
+ The endpoint URLs do not change between deploys, so no HF Space secret update
242
+ is needed unless you deploy under a different workspace.
243
+
244
+ ---
245
+
246
+ ## Troubleshooting
247
+
248
+ **`modal setup` hangs or fails**
249
+ Run `uv run modal token show`. If it shows no token, re-run `uv run modal setup`
250
+ and complete the browser authentication flow.
251
+
252
+ **Deploy command fails with "app not found"**
253
+ Check you are in the project root (`ls modal_apps/` should list the service
254
+ files) and that `uv` has Modal installed (`uv run modal --version`).
255
+
256
+ **HF Space shows no NLU trace after rebuild**
257
+ Confirm the secret name is exactly `MODAL_NLU_ENDPOINT` (no spaces, correct
258
+ case). Check the Space logs (Settings → Logs) for any startup errors.
259
+
260
+ **`curl` health check returns 502 or times out**
261
+ The container is cold-starting. Wait 30–60 seconds and retry. Modal T4 GPU
262
+ containers take longer on first cold start because the model weights are
263
+ downloaded into the container volume.
264
+
265
+ **Endpoint URL has extra `/extract` suffix**
266
+ The `write_modal_endpoint.py` script derives the URL from the deployed function.
267
+ If it appends a route that the endpoint doesn't use, edit `.env` and the HF
268
+ Space secret to remove the suffix. Test the corrected URL with `curl` before
269
+ updating the secret.
docs/plan_half_baked_features.md ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Audit: Remaining Feature Gaps For Hugging Face Spaces
2
+
3
+ Audit date: 2026-06-14. This document is now a status note for the current
4
+ FastAPI/static app running toward a Hugging Face Spaces deployment.
5
+
6
+ ## Current HF Spaces Runtime Assumptions
7
+
8
+ - Public demo runtime is the Docker Space defined by `README.md`.
9
+ - The app should default to `RECEIPT_BACKEND=hf_inference` for receipt text
10
+ parsing with `HF_RECEIPT_MODEL_REPO` set in Space secrets/settings.
11
+ - Receipt image OCR and speech transcription are optional Modal-hosted services
12
+ called through thin HTTP clients:
13
+ - `MODAL_RECEIPT_ENDPOINT`
14
+ - `MODAL_SPEECH_ENDPOINT` or `SPEECH_ASR_ENDPOINT`
15
+ - ReAct is an app-side tool router. It is not a model; it calls tools, and
16
+ model-backed tools may call Modal, HF Inference, or local llama.cpp.
17
+ - Inventory writes remain owner-approved. Model output can only create editable
18
+ receipt rows or pending stock actions.
19
+ - SQLite state on HF Spaces is ephemeral unless persistent storage is enabled
20
+ and `DB_PATH` points at `/data/...`.
21
+
22
+ ## Completed Since Original Audit
23
+
24
+ | Feature | Current status |
25
+ |---------|----------------|
26
+ | ReAct photo path | `POST /api/photo` uses `ReceiptReActAgent` first, with direct fallback. |
27
+ | ReAct voice command path | `_h_voice_command` routes through `run_command_parse`, which uses ReAct first. |
28
+ | Voice owner approval | Voice parse creates a pending action; `_h_voice_apply` writes only after explicit approval. |
29
+ | Dashboard Add to order | `_h_add_to_order` inserts a pending order row. |
30
+ | Dashboard Offer to route | `_h_offer_to_route` records a pending liquidation/order intent. |
31
+ | Dashboard insights | `run_analysis` now builds deterministic inventory/expiry prose from DB state. |
32
+ | Float quantity truncation | Immediate rounding fix added in `kirana_db.py` and `dukaan_saathi/storage.py`. |
33
+ | Receipt product matching | Parsed receipt rows are post-matched against existing inventory before display. |
34
+ | Orders Mark received | Approved orders can be marked received and stock is updated through the normal owner action. |
35
+ | Analytics date range | Analytics supports `7d`, `30d`, and `90d` seller windows. |
36
+ | Modal cold-start UX | UI copy explains cold starts; `/api/warm` fire-and-forgets Modal warm pings. |
37
+ | Safety tests | `smoke_tests/test_custom_app_safety.py` covers key approval gates and order transitions. |
38
+
39
+ ## Still Worth Doing
40
+
41
+ ### 1. Canonical inventory write boundary
42
+
43
+ The documented ideal is:
44
+
45
+ ```text
46
+ owner approval -> dukaan_saathi/services/inventory.py -> storage ledger
47
+ ```
48
+
49
+ The current custom FastAPI path still writes through `kirana_db.py`, which is a
50
+ compatibility adapter over the Dukaan storage layer. It preserves the approval
51
+ gate, but future code should either migrate these writes into
52
+ `dukaan_saathi/services/inventory.py` or keep the adapter boundary explicitly
53
+ documented.
54
+
55
+ ### 2. Fractional stock follow-through
56
+
57
+ `stock_ledger.delta` now migrates to `REAL`, so fractional stock is supported at
58
+ the storage layer. Keep checking UI formatting, reorder math, and tests whenever
59
+ quantity semantics change.
60
+
61
+ ### 3. HF Spaces persistence decision
62
+
63
+ For a hackathon demo, ephemeral SQLite may be acceptable. For a realistic public
64
+ Space, decide whether to:
65
+
66
+ - keep session-local state and reset on rebuild, or
67
+ - enable HF persistent storage and set `DB_PATH=/data/dukaan.db`.
68
+
69
+ Document the chosen behavior in the Space README/settings.
70
+
71
+ ### 4. Modal endpoint health and warmup
72
+
73
+ `/api/warm` currently sends non-blocking `HEAD` requests. If Modal services
74
+ expose dedicated health routes, use those instead. Keep page load non-blocking
75
+ and avoid surfacing warmup failures as user-facing errors.
76
+
77
+ ### 5. Model endpoint test coverage
78
+
79
+ Add mocked tests for:
80
+
81
+ - Modal OCR success and malformed responses.
82
+ - Modal speech success and failures.
83
+ - HF Inference receipt parser success and malformed JSON fallback.
84
+ - Modal receipt LLM success and malformed JSON fallback.
85
+
86
+ ### 6. Voice NLU quality
87
+
88
+ The current parser is still deterministic/keyword-oriented. For stronger
89
+ Telugu/code-mixed commands on Spaces, add an optional HF Inference voice-NLU
90
+ path with deterministic fallback and the same owner approval gate.
91
+
92
+ ## Lower Priority Ideas
93
+
94
+ - LLM-generated dashboard prose after deterministic insights are stable.
95
+ - Expanded receipt fine-tuning data and benchmark reports.
96
+ - Liquidation-agent routing through WhatsApp/SMS after the order-intent stub is
97
+ enough for the demo.
docs/plan_react_agent.md ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ReAct Agent Status For Hugging Face Spaces
2
+
3
+ This document describes the current role of the lean ReAct router in the
4
+ HF Spaces-oriented runtime.
5
+
6
+ ## Role
7
+
8
+ ReAct is the app-side orchestrator, not the model.
9
+
10
+ It records:
11
+
12
+ ```text
13
+ Thought -> Action -> Observation
14
+ ```
15
+
16
+ and chooses the smallest safe tool chain for a task. The tools may call
17
+ deterministic Python, Hugging Face Inference, Modal endpoints, or local
18
+ llama.cpp depending on configuration.
19
+
20
+ ReAct must never write inventory. It returns editable rows, pending actions, and
21
+ trace lines. Owner approval remains the write boundary.
22
+
23
+ ## Current Live Paths
24
+
25
+ ### Receipt photo
26
+
27
+ ```text
28
+ POST /api/photo
29
+ -> ReceiptReActAgent.extract_receipt_image
30
+ -> extract_text_from_receipt_image tool
31
+ -> dukaan_saathi/integrations/modal_receipt.py
32
+ -> MODAL_RECEIPT_ENDPOINT
33
+ -> modal_apps/receipt_vlm_service.py
34
+ -> parse_receipt_text_tool
35
+ -> hf_inference / modal_llm / llamacpp / deterministic
36
+ -> post-match rows to inventory catalog
37
+ -> editable receipt rows in UI
38
+ -> owner applies row
39
+ -> inventory write
40
+ ```
41
+
42
+ For HF Spaces, the preferred receipt text parser backend is
43
+ `RECEIPT_BACKEND=hf_inference` with `HF_RECEIPT_MODEL_REPO` set.
44
+
45
+ ### Voice command
46
+
47
+ ```text
48
+ POST /api/speech
49
+ -> Modal ASR endpoint
50
+ -> transcript
51
+ -> _h_voice_command
52
+ -> run_command_parse
53
+ -> ReceiptReActAgent.parse_stock_command
54
+ -> pending stock action
55
+ -> owner clicks Approve stock change
56
+ -> _h_voice_apply
57
+ -> inventory write
58
+ ```
59
+
60
+ The ASR step is separate from ReAct. ReAct starts after text exists.
61
+
62
+ ### Receipt text
63
+
64
+ The Gradio path already routes text parsing through the ReAct agent with
65
+ configured fallback behavior. The custom FastAPI path uses ReAct for photo OCR
66
+ flows, then the parser tool for receipt text.
67
+
68
+ ## Tool Responsibilities
69
+
70
+ - `extract_text_from_receipt_image`: calls the Modal OCR HTTP client.
71
+ - `parse_receipt_text_tool`: respects `RECEIPT_BACKEND`.
72
+ - `parse_stock_command_tool`: uses the deterministic stock parser today.
73
+ - `draft_reorder_tool`: reads inventory and drafts reorder suggestions.
74
+ - `propose_inventory_update`: stores a pending proposal only; no DB write.
75
+
76
+ Modal/HF/llama.cpp inference belongs behind tools or integration clients, not
77
+ inside UI handlers.
78
+
79
+ ## Completed
80
+
81
+ - Custom FastAPI photo path calls `ReceiptReActAgent` first.
82
+ - Custom FastAPI voice command path calls `ReceiptReActAgent` first.
83
+ - Voice trace is shown in the parsed voice result panel.
84
+ - Receipt rows are returned as editable rows and post-matched against inventory.
85
+ - Voice actions are pending until owner approval.
86
+ - Direct fallback paths remain for robustness if ReAct or a configured backend
87
+ fails.
88
+
89
+ ## Remaining Cleanup
90
+
91
+ ### 1. Heavier ToolCallingAgent
92
+
93
+ `dukaan_saathi/agent/agent.py` still contains the heavier smolagents
94
+ `ToolCallingAgent`. For HF Spaces, do not make this primary unless there is a
95
+ clear demo need. If revived, it should use an HF-compatible model client and
96
+ must preserve the same owner approval gate.
97
+
98
+ ### 2. Multi-step agent UX
99
+
100
+ Do not add broad agent chat unless it has a concrete user workflow. The current
101
+ demo benefits more from reliable receipt, voice, reorder, and approval flows
102
+ than from open-ended agent chat.
103
+
104
+ ## HF Spaces Guidance
105
+
106
+ - Prefer `hf_inference` for receipt text parsing in the public Space.
107
+ - Use Modal for OCR and ASR only when endpoint env vars are configured.
108
+ - Keep `/api/warm` best-effort and non-blocking.
109
+ - Keep deterministic parser fallbacks for tests and graceful degradation.
110
+ - Never require local llama.cpp in the Space runtime.
docs/plan_voice_command_agent.md ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Voice Command Status For Hugging Face Spaces
2
+
3
+ This document tracks the current voice command pipeline and the remaining work
4
+ that matters for a public Hugging Face Space.
5
+
6
+ ## Current Pipeline
7
+
8
+ Voice has two separate stages:
9
+
10
+ ```text
11
+ audio
12
+ -> POST /api/speech
13
+ -> dukaan_saathi/integrations/speech.py
14
+ -> MODAL_SPEECH_ENDPOINT or SPEECH_ASR_ENDPOINT
15
+ -> transcript
16
+ -> owner reviews/edits text
17
+ -> _h_voice_command
18
+ -> ReAct stock command tool
19
+ -> pending stock action
20
+ -> owner approves
21
+ -> _h_voice_apply
22
+ -> inventory write
23
+ ```
24
+
25
+ The Modal ASR endpoint does speech-to-text only. ReAct starts after text exists.
26
+
27
+ ## Completed
28
+
29
+ - Field names are normalized to the UI shape:
30
+ - `action`
31
+ - `product`
32
+ - `product_id`
33
+ - `quantity`
34
+ - `unit`
35
+ - `confidence`
36
+ - `trace`
37
+ - `add_stock` and `set_stock` are both handled.
38
+ - The parser uses the returned `product_id`; it does not re-match blindly.
39
+ - Parsed commands no longer auto-apply.
40
+ - The UI shows a pending parsed action and requires **Approve stock change**.
41
+ - `_h_voice_apply` is the only custom FastAPI voice handler that writes stock.
42
+ - Modal cold-start copy is visible and `/api/warm` runs best-effort on page load.
43
+ - Safety tests cover parse-without-write and apply-with-write.
44
+
45
+ ## Current Limitations
46
+
47
+ | Gap | Impact on HF Space |
48
+ |-----|--------------------|
49
+ | Deterministic command parser | Reliable for seeded/demo examples, weaker for natural Telugu/code-mix. |
50
+ | Limited product aliases | Commands such as "tamatar" need aliases or NLU to map to seeded products. |
51
+ | Modal ASR cold start | First request may take 10-30 seconds unless endpoint is warm. |
52
+ | Ephemeral SQLite | Approved stock changes may reset on Space rebuild unless persistent storage is enabled. |
53
+
54
+ ## Recommended Next Steps
55
+
56
+ ### 1. Keep deterministic parser as the default
57
+
58
+ For the hackathon/public Space, deterministic parsing is safer and easier to
59
+ debug. Continue using seeded examples that map to inventory:
60
+
61
+ ```text
62
+ add Bun 12
63
+ set OBM stock 5
64
+ add Bingo 4
65
+ Happy Happy low
66
+ ```
67
+
68
+ Do not bypass owner approval to make voice feel more automatic.
69
+
70
+ ### 2. Add optional HF Inference voice NLU
71
+
72
+ If Telugu/code-mixed commands are important for the Space demo, add an optional
73
+ HF Inference path behind a feature flag:
74
+
75
+ ```text
76
+ VOICE_LLM_BACKEND=keyword | hf_inference
77
+ HF_VOICE_NLU_MODEL_REPO=...
78
+ ```
79
+
80
+ The output contract should stay the same:
81
+
82
+ ```json
83
+ {
84
+ "action": "add_stock|set_stock|mark_out_of_stock|unknown",
85
+ "product_name": "string or null",
86
+ "product_id": "string or null",
87
+ "quantity": "number or null",
88
+ "unit": "string or null",
89
+ "confidence": "low|medium|high"
90
+ }
91
+ ```
92
+
93
+ Fallback to the deterministic parser on malformed JSON, low confidence, missing
94
+ product match, timeout, or missing env vars.
95
+
96
+ ### 3. Improve aliases before adding broad NLU
97
+
98
+ For a constrained demo, aliases often beat another model call:
99
+
100
+ - Add common transliterations for seeded products.
101
+ - Keep examples aligned to seeded inventory.
102
+ - Add parser tests for each new alias.
103
+
104
+ ### 4. Preserve the approval gate
105
+
106
+ Any voice NLU path must still produce only a pending action:
107
+
108
+ ```text
109
+ model/parser output -> pending action -> owner approval -> inventory write
110
+ ```
111
+
112
+ No model, parser, or ReAct step may write inventory directly.
113
+
114
+ ## Tests To Keep
115
+
116
+ - Voice parse does not change stock.
117
+ - Voice apply changes stock.
118
+ - Unknown/low-confidence commands do not expose an approval button.
119
+ - Malformed model output falls back or returns `unknown`.
120
+ - Missing Modal ASR endpoint produces a useful UI error, not a crash.
dukaan_saathi/__init__.py ADDED
File without changes
dukaan_saathi/agent/__init__.py ADDED
File without changes
dukaan_saathi/agent/agent.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ agent.py — legacy smolagents ToolCallingAgent for Dukaan Saathi.
3
+
4
+ The active Gradio path uses dukaan_saathi.agent.react_agent. This module is kept
5
+ as an optional heavier agent implementation for experiments with model-driven
6
+ tool calling.
7
+
8
+ The agent proposes actions but never writes to the inventory database directly —
9
+ all writes go through the Gradio approval step (approve_command_action /
10
+ approve_receipt_rows called outside the agent loop).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import logging
16
+ from functools import lru_cache
17
+
18
+ from smolagents import ToolCallingAgent, OpenAIServerModel
19
+
20
+ from dukaan_saathi import config
21
+ from dukaan_saathi.agent.tools import (
22
+ get_inventory_snapshot,
23
+ parse_stock_command_tool,
24
+ extract_text_from_receipt_image,
25
+ parse_receipt_text_tool,
26
+ apply_correction_to_receipt,
27
+ transcribe_audio_tool,
28
+ draft_reorder_tool,
29
+ propose_inventory_update,
30
+ )
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ SYSTEM_PROMPT = """You are Dukaan Saathi, an inventory assistant for a Telugu-speaking kirana (convenience store) owner.
35
+
36
+ Your job:
37
+ 1. Understand the owner's request (stock commands in Telugu/English, receipt photos, reorder drafts).
38
+ 2. Call the appropriate tools to parse and structure the data.
39
+ 3. Always call propose_inventory_update before finishing — the owner must approve before any changes are written.
40
+
41
+ Rules:
42
+ - Never write to the inventory database directly. Only propose.
43
+ - If a stock command is unclear, ask for clarification rather than guessing.
44
+ - For receipt parsing: first extract text with extract_text_from_receipt_image, then parse with parse_receipt_text_tool.
45
+ - Respond concisely in English (the owner reads English labels even if they speak Telugu).
46
+ """
47
+
48
+
49
+ def _build_agent() -> ToolCallingAgent:
50
+ model = OpenAIServerModel(
51
+ model_id="llama-3.2-3b",
52
+ api_base=f"{config.LLAMACPP_HOST}:8080/v1",
53
+ api_key="none",
54
+ )
55
+ return ToolCallingAgent(
56
+ tools=[
57
+ get_inventory_snapshot,
58
+ parse_stock_command_tool,
59
+ extract_text_from_receipt_image,
60
+ parse_receipt_text_tool,
61
+ apply_correction_to_receipt,
62
+ transcribe_audio_tool,
63
+ draft_reorder_tool,
64
+ propose_inventory_update,
65
+ ],
66
+ model=model,
67
+ system_prompt=SYSTEM_PROMPT,
68
+ max_steps=6,
69
+ )
70
+
71
+
72
+ # Module-level agent instance — created once at import.
73
+ # Re-create if the llama.cpp server wasn't up on first import.
74
+ _agent: ToolCallingAgent | None = None
75
+
76
+
77
+ def get_agent() -> ToolCallingAgent:
78
+ """Return the module-level agent, building it on first call."""
79
+ global _agent
80
+ if _agent is None:
81
+ try:
82
+ _agent = _build_agent()
83
+ except Exception as exc:
84
+ logger.warning(f"Could not build agent (llama.cpp not running?): {exc}")
85
+ raise
86
+ return _agent
87
+
88
+
89
+ def format_agent_trace(agent: ToolCallingAgent) -> str:
90
+ """Format agent.logs into a human-readable trace string for the UI."""
91
+ lines: list[str] = []
92
+ for step in agent.logs:
93
+ step_str = str(step)
94
+ if step_str.strip():
95
+ lines.append(step_str)
96
+ return "\n\n".join(lines) if lines else "(no agent trace)"
dukaan_saathi/agent/react_agent.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+ from dukaan_saathi.agent import tools
8
+
9
+
10
+ @dataclass
11
+ class ReactResult:
12
+ trace: list[str]
13
+ action: dict[str, Any] | None = None
14
+ receipt_rows: list[dict[str, Any]] | None = None
15
+ raw_text: str | None = None
16
+
17
+
18
+ def _preview(value: Any, limit: int = 240) -> str:
19
+ text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
20
+ if len(text) <= limit:
21
+ return text
22
+ return f"{text[:limit]}..."
23
+
24
+
25
+ class ReceiptReActAgent:
26
+ """
27
+ Lean ReAct-style router for the small Dukaan Saathi task set.
28
+
29
+ It chooses from existing safe tools, records Thought/Action/Observation
30
+ traces, and never writes inventory. Approval functions remain outside the
31
+ agent path.
32
+ """
33
+
34
+ def _trace(self) -> list[str]:
35
+ tools.reset_state()
36
+ return ["Thought: Identify the user workflow and select the smallest safe tool chain."]
37
+
38
+ def parse_stock_command(self, command: str) -> ReactResult:
39
+ trace = self._trace()
40
+ trace.append("Thought: This is a stock command, so parse it into a pending owner action.")
41
+ trace.append("Action: parse_stock_command_tool")
42
+ output = tools.parse_stock_command_tool(command=command)
43
+ action = tools.get_last_action()
44
+ trace.append(f"Observation: {_preview(output)}")
45
+ trace.append("Thought: Return the proposed action for owner approval; do not write inventory.")
46
+ return ReactResult(trace=trace, action=action)
47
+
48
+ def parse_receipt_text(self, raw_text: str) -> ReactResult:
49
+ trace = self._trace()
50
+ trace.append("Thought: This is receipt text, so parse it into editable receipt rows.")
51
+ trace.append("Action: parse_receipt_text_tool")
52
+ output = tools.parse_receipt_text_tool(raw_text=raw_text)
53
+ rows = tools.get_last_receipt_rows() or []
54
+ trace.append(f"Observation: {_preview(output)}")
55
+ trace.append("Thought: Return editable rows; owner approval is required before stock changes.")
56
+ return ReactResult(trace=trace, receipt_rows=rows)
57
+
58
+ def extract_receipt_image(self, image_path: str) -> ReactResult:
59
+ trace = self._trace()
60
+ trace.append("Thought: This is a receipt image, so extract OCR text before parsing rows.")
61
+ trace.append("Action: extract_text_from_receipt_image")
62
+ raw_text = tools.extract_text_from_receipt_image(image_path=image_path)
63
+ trace.append(f"Observation: {_preview(raw_text)}")
64
+
65
+ if not raw_text.strip():
66
+ trace.append("Thought: No OCR text was returned; caller should use the image fallback path.")
67
+ return ReactResult(trace=trace, raw_text=raw_text, receipt_rows=[])
68
+
69
+ trace.append("Thought: OCR text is available, so parse it into editable receipt rows.")
70
+ trace.append("Action: parse_receipt_text_tool")
71
+ output = tools.parse_receipt_text_tool(raw_text=raw_text)
72
+ rows = tools.get_last_receipt_rows() or []
73
+ trace.append(f"Observation: {_preview(output)}")
74
+ trace.append("Thought: Return editable rows; owner approval is required before stock changes.")
75
+ return ReactResult(trace=trace, raw_text=raw_text, receipt_rows=rows)
76
+
77
+
78
+ _react_agent: ReceiptReActAgent | None = None
79
+
80
+
81
+ def get_react_agent() -> ReceiptReActAgent:
82
+ global _react_agent
83
+ if _react_agent is None:
84
+ _react_agent = ReceiptReActAgent()
85
+ return _react_agent
dukaan_saathi/agent/tools.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tools.py — smolagents @tool definitions for Dukaan Saathi.
3
+
4
+ Each tool wraps an existing service/parser function and returns a JSON string
5
+ so the active ReAct router or optional ToolCallingAgent can reason over results.
6
+
7
+ Results are also stored in _state so Gradio handlers can access structured data
8
+ after agent.run() without having to parse agent.logs internals.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from typing import Any
15
+
16
+ from smolagents import tool
17
+
18
+ # Shared session state — stores last structured result from each tool.
19
+ # Single-user app (kirana owner's phone), so no concurrency concern.
20
+ _state: dict[str, Any] = {
21
+ "last_action": None,
22
+ "last_proposal": None,
23
+ "last_receipt_rows": None,
24
+ "last_raw_text": None,
25
+ }
26
+
27
+
28
+ def reset_state() -> None:
29
+ _state.update(
30
+ {
31
+ "last_action": None,
32
+ "last_proposal": None,
33
+ "last_receipt_rows": None,
34
+ "last_raw_text": None,
35
+ }
36
+ )
37
+
38
+
39
+ def get_last_action() -> dict | None:
40
+ return _state.get("last_action")
41
+
42
+
43
+ def get_last_proposal() -> Any:
44
+ return _state.get("last_proposal")
45
+
46
+
47
+ def get_last_receipt_rows() -> list[dict] | None:
48
+ return _state.get("last_receipt_rows")
49
+
50
+
51
+ def get_last_raw_text() -> str | None:
52
+ return _state.get("last_raw_text")
53
+
54
+
55
+ # ── Read-only tools ────────────────────────────────────────────────────────────
56
+
57
+ @tool
58
+ def get_inventory_snapshot() -> str:
59
+ """Returns the current inventory of the kirana store as a JSON string.
60
+
61
+ Use this before proposing any inventory changes.
62
+ """
63
+ from dukaan_saathi.storage import get_inventory
64
+ items = get_inventory()
65
+ return json.dumps(items)
66
+
67
+
68
+ @tool
69
+ def draft_reorder_tool() -> str:
70
+ """Generates a reorder purchase-order draft based on current low-stock items.
71
+
72
+ Returns a JSON list of suggested orders.
73
+ """
74
+ from dukaan_saathi.services.reorder import draft_reorder
75
+ rows, _ = draft_reorder()
76
+ return json.dumps(rows)
77
+
78
+
79
+ # ── Parsing tools ──────────────────────────────────────────────────────────────
80
+
81
+ @tool
82
+ def parse_stock_command_tool(command: str) -> str:
83
+ """Parse a natural language stock command (Telugu/English code-mixed) into a
84
+ proposed inventory action. Input examples: "add Bun 12",
85
+ "set OBM stock 5", "Happy Happy low". Returns a JSON action dict.
86
+
87
+ Args:
88
+ command: The owner's raw stock command text.
89
+ """
90
+ from dukaan_saathi.parsers.stock_command import parse_stock_command
91
+ action, _ = parse_stock_command(command)
92
+ _state["last_action"] = action
93
+ return json.dumps(action)
94
+
95
+
96
+ @tool
97
+ def extract_text_from_receipt_image(image_path: str) -> str:
98
+ """Extract raw OCR text from a receipt image using MiniCPM-V 4.6 on the Modal
99
+ endpoint. Returns the raw pipe-separated text output from the vision model.
100
+ Must be followed by parse_receipt_text_tool to get structured rows.
101
+
102
+ Args:
103
+ image_path: Local filesystem path to the uploaded receipt image.
104
+ """
105
+ from dukaan_saathi.integrations.modal_receipt import _extract_receipt_result_with_modal
106
+ result = _extract_receipt_result_with_modal(image_path)
107
+ raw_text = result.raw_text or ""
108
+ _state["last_raw_text"] = raw_text
109
+ return raw_text
110
+
111
+
112
+ @tool
113
+ def parse_receipt_text_tool(raw_text: str) -> str:
114
+ """Parse OCR receipt text into structured line items. Uses the configured
115
+ receipt backend: HF Inference API, local llama.cpp, Modal-hosted LLM, or deterministic parser.
116
+ Returns a JSON list of row dicts with fields: product_raw,
117
+ matched_product_name, quantity, unit_price, total_price.
118
+
119
+ Args:
120
+ raw_text: Receipt OCR text or pasted receipt text to parse.
121
+ """
122
+ from dukaan_saathi import config
123
+ if config.RECEIPT_BACKEND == "hf_inference":
124
+ from dukaan_saathi.integrations.hf_inference_receipt import parse_receipt_via_hf_inference
125
+ rows, _ = parse_receipt_via_hf_inference(raw_text)
126
+ elif config.RECEIPT_BACKEND == "llamacpp":
127
+ from dukaan_saathi.integrations.llamacpp_receipt import parse_receipt_via_llm
128
+ rows, _ = parse_receipt_via_llm(raw_text)
129
+ elif config.RECEIPT_BACKEND == "modal_llm":
130
+ from dukaan_saathi.integrations.modal_receipt_llm import parse_receipt_with_modal_llm
131
+ rows, _ = parse_receipt_with_modal_llm(raw_text)
132
+ else:
133
+ from dukaan_saathi.parsers.receipt_text import parse_receipt_text
134
+ rows, _ = parse_receipt_text(raw_text)
135
+ _state["last_receipt_rows"] = rows
136
+ return json.dumps(rows)
137
+
138
+
139
+ @tool
140
+ def apply_correction_to_receipt(rows_json: str, correction_command: str) -> str:
141
+ """Apply a human correction command to receipt rows. Correction examples:
142
+ "first one Parle bulk, second one Bingo", "skip row 3", "row 2 quantity 10".
143
+ Returns updated rows as JSON.
144
+
145
+ Args:
146
+ rows_json: JSON array of editable receipt row dictionaries.
147
+ correction_command: Owner's typed correction command.
148
+ """
149
+ from dukaan_saathi.parsers.receipt_correction import apply_receipt_correction_command
150
+ rows = json.loads(rows_json)
151
+ updated_rows, _ = apply_receipt_correction_command(rows, correction_command)
152
+ _state["last_receipt_rows"] = updated_rows
153
+ return json.dumps(updated_rows)
154
+
155
+
156
+ @tool
157
+ def transcribe_audio_tool(audio_path: str) -> str:
158
+ """Transcribe a correction audio recording to text using Distil-Whisper via
159
+ the Modal ASR endpoint. Returns the transcription string.
160
+
161
+ Args:
162
+ audio_path: Local filesystem path to the audio file.
163
+ """
164
+ from dukaan_saathi.integrations.speech import transcribe_audio
165
+ transcript, _ = transcribe_audio(audio_path)
166
+ return transcript
167
+
168
+
169
+ # ── Proposal tool (write-gate) ─────────────────────────────────────────────────
170
+
171
+ @tool
172
+ def propose_inventory_update(changes_json: str) -> str:
173
+ """Propose inventory changes for human review. This tool does NOT write to the
174
+ database — it formats the proposed changes and returns them for display.
175
+ The owner must click the Approve button in the UI to apply the changes.
176
+
177
+ Args:
178
+ changes_json: JSON object or array describing proposed inventory changes.
179
+ """
180
+ try:
181
+ changes = json.loads(changes_json)
182
+ _state["last_proposal"] = changes
183
+ if isinstance(changes, dict) and changes.get("status") == "pending_approval":
184
+ _state["last_action"] = changes
185
+ lines = [
186
+ "Proposed inventory update (pending owner approval):",
187
+ json.dumps(changes, indent=2, ensure_ascii=False),
188
+ ]
189
+ return "\n".join(lines)
190
+ except (json.JSONDecodeError, TypeError) as exc:
191
+ return f"Could not format proposal: {exc}\nRaw: {changes_json}"
dukaan_saathi/config.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import os
3
+
4
+
5
+ APP_NAME = "Dukaan Saathi"
6
+ DB_PATH = os.getenv("DB_PATH", "data/dukaan.db")
7
+ DATA_DIR = Path("data")
8
+ SAMPLES_DIR = Path("samples")
9
+
10
+ # Receipt parsing backend:
11
+ # - "hf_inference" uses the fine-tuned model on Hugging Face Inference API.
12
+ # - "llamacpp" uses local llama.cpp servers.
13
+ # - "modal_llm" uses a Modal-hosted receipt parser endpoint.
14
+ # - "deterministic" uses the rule-based Python parser.
15
+ RECEIPT_BACKEND = os.getenv("RECEIPT_BACKEND", "hf_inference")
16
+
17
+ # Base URL for the local llama.cpp HTTP servers (OpenAI-compatible).
18
+ LLAMACPP_HOST = os.getenv("LLAMACPP_HOST", "http://localhost")
19
+
20
+ # HF Hub model repo for the fine-tuned receipt model GGUF (used by scripts/download_models.py).
21
+ HF_RECEIPT_MODEL_REPO = os.getenv("HF_RECEIPT_MODEL_REPO", "")
22
+
23
+ # Modal endpoint for receipt text parsing with a hosted LoRA/base model.
24
+ MODAL_RECEIPT_LLM_ENDPOINT = os.getenv("MODAL_RECEIPT_LLM_ENDPOINT", "")
dukaan_saathi/integrations/__init__.py ADDED
File without changes
dukaan_saathi/integrations/command_nlu.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ import requests
6
+
7
+
8
+ def extract_command_slots(command: str) -> dict | None:
9
+ """
10
+ Call the Modal NLU endpoint to extract structured slots from a raw command.
11
+
12
+ Returns a dict with keys: intent, product_name, quantity, unit, confidence, model.
13
+ Returns None if the endpoint is not configured, the request fails, or the
14
+ response cannot be parsed — callers must fall back to the deterministic parser.
15
+
16
+ Safety boundary:
17
+ - This only returns structured slots.
18
+ - It does not look up products.
19
+ - It does not apply inventory changes.
20
+ - It does not approve anything.
21
+ """
22
+ endpoint = os.getenv("MODAL_NLU_ENDPOINT", "").strip()
23
+ if not endpoint:
24
+ return None
25
+
26
+ try:
27
+ response = requests.post(
28
+ endpoint,
29
+ json={"command": command},
30
+ timeout=60,
31
+ )
32
+ response.raise_for_status()
33
+ slots = response.json()
34
+ except (requests.RequestException, ValueError):
35
+ return None
36
+
37
+ intent = slots.get("intent", "unknown")
38
+ if intent not in {"add_stock", "set_stock", "mark_low", "mark_out", "unknown"}:
39
+ return None
40
+
41
+ return {
42
+ "intent": intent,
43
+ "product_name": slots.get("product_name"),
44
+ "quantity": slots.get("quantity"),
45
+ "unit": slots.get("unit"),
46
+ "confidence": slots.get("confidence", "medium"),
47
+ "model": slots.get("model", "unknown"),
48
+ }
dukaan_saathi/integrations/hf_inference_receipt.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ hf_inference_receipt.py — Receipt parsing via HF Inference API.
3
+
4
+ Uses the fine-tuned model pushed to HF Hub after training.
5
+ Requires no running Modal service — calls HF Serverless Inference directly.
6
+
7
+ Push the model to Hub first:
8
+ modal run modal_apps/receipt_llm_service.py::push \\
9
+ --hf-repo-id summerdevlin46/dukaan-saathi-receipt-lora \\
10
+ --hf-token hf_...
11
+
12
+ Then set HF_RECEIPT_MODEL_REPO (or rely on the default) and optionally HF_TOKEN.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import logging
19
+ import os
20
+ from typing import Any
21
+
22
+ from dukaan_saathi.storage import find_product
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ SYSTEM_PROMPT = (
27
+ "You are a receipt parser for an Indian convenience store. "
28
+ "Extract all line items from the receipt text. "
29
+ "Return ONLY valid JSON with this structure: "
30
+ '{"supplier": "...", "invoice_no": "...", "date": "YYYY-MM-DD", '
31
+ '"items": [{"product_raw": "...", "qty_cases": 0, "qty_units": 0, '
32
+ '"unit_cost": 0.0, "total": 0.0}], '
33
+ '"subtotal": 0.0, "discount": 0.0, "gst": 0.0, "net_total": 0.0}. '
34
+ "No markdown, no explanation."
35
+ )
36
+
37
+ INSTRUCTION_TEMPLATE = """### Instruction:
38
+ {system}
39
+
40
+ ### Input:
41
+ {input}
42
+
43
+ ### Response:
44
+ """
45
+
46
+
47
+ def _item_to_row(item: dict[str, Any], supplier: str, document_type: str) -> dict[str, Any]:
48
+ from dukaan_saathi.integrations.llamacpp_receipt import _llm_item_to_row
49
+ return _llm_item_to_row(item, supplier, document_type)
50
+
51
+
52
+ def parse_receipt_via_hf_inference(raw_text: str) -> tuple[list[dict[str, Any]], list[str]]:
53
+ """
54
+ Parse receipt OCR text using the fine-tuned model on HF Hub via Inference API.
55
+ Falls back to the deterministic parser only if the model repo is not configured.
56
+ Raises on endpoint errors so the caller sees a real failure, not silent determinism.
57
+ """
58
+ from huggingface_hub import InferenceClient
59
+ from dukaan_saathi.parsers.receipt_text import detect_document_type, detect_supplier
60
+
61
+ model_repo = os.getenv("HF_RECEIPT_MODEL_REPO", "").strip()
62
+ if not model_repo:
63
+ raise ValueError(
64
+ "HF_RECEIPT_MODEL_REPO is not set. "
65
+ "Push the model first: modal run modal_apps/receipt_llm_service.py::push "
66
+ "--hf-repo-id <repo> --hf-token <token>, "
67
+ "then set HF_RECEIPT_MODEL_REPO=<repo>."
68
+ )
69
+
70
+ trace: list[str] = [f"[hf_inference] Calling {model_repo} via HF Inference API"]
71
+
72
+ prompt = INSTRUCTION_TEMPLATE.format(system=SYSTEM_PROMPT, input=raw_text)
73
+ # token is only needed for private models; if the repo is public, omit it
74
+ token = os.getenv("HF_TOKEN", "").strip() or None
75
+ client = InferenceClient(token=token)
76
+
77
+ response_text = client.text_generation(
78
+ prompt,
79
+ model=model_repo,
80
+ max_new_tokens=768,
81
+ temperature=0.1,
82
+ do_sample=False,
83
+ )
84
+
85
+ response_text = response_text.strip()
86
+ parsed = json.loads(response_text)
87
+ items = parsed.get("items", [])
88
+ if not items:
89
+ raise ValueError("HF Inference API returned zero items")
90
+
91
+ supplier = parsed.get("supplier") or detect_supplier(raw_text)
92
+ document_type = detect_document_type(raw_text)
93
+
94
+ trace.append(f"[hf_inference] Parsed supplier: {supplier}")
95
+ trace.append(f"[hf_inference] Extracted {len(items)} items")
96
+
97
+ rows = []
98
+ for item in items:
99
+ row = _item_to_row(item, supplier, document_type)
100
+ rows.append(row)
101
+ if row["matched_product_name"]:
102
+ trace.append(f"[hf_inference] Matched '{row['product_raw']}' → {row['matched_product_name']}")
103
+ else:
104
+ trace.append(f"[hf_inference] Needs owner review: '{row['product_raw']}'")
105
+
106
+ trace.append(f"[hf_inference] {len(rows)} candidate line items")
107
+ return rows, trace
dukaan_saathi/integrations/hub_traces.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Push agent traces to a public HF dataset repo for the "Sharing is Caring"
3
+ hackathon badge.
4
+
5
+ Runs in a daemon thread — never blocks the approval flow.
6
+ No-op when HF_TOKEN is unset.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import threading
14
+ from datetime import datetime, timezone
15
+
16
+
17
+ DATASET_REPO = "Zappandy/kirana-ai-agent-traces"
18
+
19
+
20
+ def push_trace(
21
+ *,
22
+ input_type: str,
23
+ raw_command: str,
24
+ trace: list[str],
25
+ action: str,
26
+ product: str,
27
+ quantity: float | None,
28
+ ) -> None:
29
+ """Fire-and-forget: push one trace entry to the Hub dataset."""
30
+ token = os.getenv("HF_TOKEN", "").strip()
31
+ if not token:
32
+ return
33
+
34
+ payload = {
35
+ "timestamp": datetime.now(timezone.utc).isoformat(),
36
+ "input_type": input_type,
37
+ "raw_command": raw_command,
38
+ "trace": trace,
39
+ "action": action,
40
+ "product": product,
41
+ "quantity": quantity,
42
+ }
43
+ threading.Thread(target=_upload, args=(payload, token), daemon=True).start()
44
+
45
+
46
+ def _upload(payload: dict, token: str) -> None:
47
+ try:
48
+ from huggingface_hub import HfApi
49
+
50
+ api = HfApi(token=token)
51
+
52
+ # Create repo on first use; no-op if it already exists.
53
+ api.create_repo(
54
+ repo_id=DATASET_REPO,
55
+ repo_type="dataset",
56
+ exist_ok=True,
57
+ private=False,
58
+ )
59
+
60
+ ts = payload["timestamp"].replace(":", "-").replace(".", "-")
61
+ path_in_repo = f"traces/{ts}.json"
62
+
63
+ api.upload_file(
64
+ path_or_fileobj=json.dumps(payload, ensure_ascii=False, indent=2).encode(),
65
+ path_in_repo=path_in_repo,
66
+ repo_id=DATASET_REPO,
67
+ repo_type="dataset",
68
+ commit_message=f"trace: {payload['input_type']} / {payload['action']}",
69
+ )
70
+ except Exception:
71
+ pass # traces are best-effort; never break the approval flow
dukaan_saathi/integrations/llamacpp_llm.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LLM wrapper — calls the llama.cpp HTTP server running on localhost.
3
+
4
+ llama.cpp is started as a background process in the HF Space via startup.sh.
5
+ Each model is loaded as a separate server instance on different ports:
6
+
7
+ Port 8080 — llama-3.2-3b-instruct.Q4_K_M.gguf (agent orchestrator)
8
+ Port 8082 — llama-3.2-3b-receipt.Q4_K_M.gguf (fine-tuned receipt text parser)
9
+
10
+ All models are served via the OpenAI-compatible /v1/chat/completions endpoint.
11
+ """
12
+
13
+ import json
14
+ import logging
15
+ from typing import Optional
16
+ import urllib.request
17
+ import urllib.error
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ MODEL_PORTS = {
22
+ "llama-3.2-3b": 8080, # base model, agent orchestrator
23
+ "llama-3.2-3b-receipt": 8082, # fine-tuned receipt text parser
24
+ }
25
+
26
+ DEFAULT_MODEL = "llama-3.2-3b"
27
+
28
+
29
+ class Session:
30
+ """
31
+ Stateful conversation session.
32
+
33
+ Keeps the full inventory dict in the system prompt so the model always
34
+ sees current stock. Only the last `history_turns` exchanges are sent as
35
+ message history — enough for follow-up questions without unbounded growth.
36
+ """
37
+
38
+ def __init__(
39
+ self,
40
+ model: str = DEFAULT_MODEL,
41
+ system: str = "",
42
+ inventory: Optional[dict] = None,
43
+ history_turns: int = 3,
44
+ **llm_kwargs,
45
+ ):
46
+ self.model = model
47
+ self._base_system = system
48
+ self.inventory: dict = inventory or {}
49
+ self.history_turns = history_turns
50
+ self.llm_kwargs = llm_kwargs
51
+ self._recent: list[dict] = []
52
+
53
+ # ------------------------------------------------------------------
54
+ # Inventory management
55
+ # ------------------------------------------------------------------
56
+
57
+ def set_inventory(self, inventory: dict) -> None:
58
+ """Replace the full inventory state."""
59
+ self.inventory = inventory
60
+
61
+ def update_inventory(self, updates: dict) -> None:
62
+ """Merge updates into the inventory (shallow merge by SKU key)."""
63
+ self.inventory.update(updates)
64
+
65
+ # ------------------------------------------------------------------
66
+ # Chat
67
+ # ------------------------------------------------------------------
68
+
69
+ def chat(self, user: str) -> str:
70
+ self._recent.append({"role": "user", "content": user})
71
+ history = self._recent[-(self.history_turns * 2):]
72
+ reply = _call_with_history(
73
+ self.model, self._system_prompt(), history, **self.llm_kwargs
74
+ )
75
+ self._recent.append({"role": "assistant", "content": reply})
76
+ return reply
77
+
78
+ def reset_history(self) -> None:
79
+ """Clear conversation history without touching the inventory."""
80
+ self._recent.clear()
81
+
82
+ # ------------------------------------------------------------------
83
+ # Internal
84
+ # ------------------------------------------------------------------
85
+
86
+ def _system_prompt(self) -> str:
87
+ inventory_block = json.dumps(self.inventory, indent=2) if self.inventory else "empty"
88
+ parts = [self._base_system] if self._base_system else []
89
+ parts.append(f"Current inventory:\n{inventory_block}")
90
+ return "\n\n".join(parts)
91
+
92
+
93
+ def _call_with_history(
94
+ model: str,
95
+ system: str,
96
+ history: list[dict],
97
+ max_tokens: int = 512,
98
+ json_mode: bool = False,
99
+ temperature: float = 0.1,
100
+ ) -> str:
101
+ port = MODEL_PORTS.get(model, MODEL_PORTS[DEFAULT_MODEL])
102
+ url = f"http://localhost:{port}/v1/chat/completions"
103
+
104
+ messages = ([{"role": "system", "content": system}] if system else []) + history
105
+
106
+ payload = {
107
+ "model": model,
108
+ "messages": messages,
109
+ "max_tokens": max_tokens,
110
+ "temperature": temperature,
111
+ "stream": False,
112
+ }
113
+ if json_mode:
114
+ payload["response_format"] = {"type": "json_object"}
115
+
116
+ try:
117
+ data = json.dumps(payload).encode("utf-8")
118
+ req = urllib.request.Request(
119
+ url,
120
+ data=data,
121
+ headers={"Content-Type": "application/json"},
122
+ method="POST",
123
+ )
124
+ with urllib.request.urlopen(req, timeout=60) as resp:
125
+ body = json.loads(resp.read().decode("utf-8"))
126
+ return body["choices"][0]["message"]["content"]
127
+
128
+ except urllib.error.URLError as e:
129
+ logger.error(f"llama.cpp unreachable at port {port}: {e}")
130
+ if port != MODEL_PORTS[DEFAULT_MODEL]:
131
+ logger.warning(f"Retrying with default model on port {MODEL_PORTS[DEFAULT_MODEL]}")
132
+ return _call_with_history(DEFAULT_MODEL, system, history, max_tokens, json_mode, temperature)
133
+ return '{"error": "llama.cpp server unavailable"}'
134
+
135
+ except (KeyError, json.JSONDecodeError) as e:
136
+ logger.error(f"Unexpected response from llama.cpp: {e}")
137
+ return '{"error": "malformed response"}'
138
+
139
+
140
+ def call_llm(
141
+ model: str,
142
+ system: str,
143
+ user: str,
144
+ max_tokens: int = 512,
145
+ json_mode: bool = False,
146
+ temperature: float = 0.1,
147
+ ) -> str:
148
+ """
149
+ Call llama.cpp HTTP server (single-turn, stateless).
150
+ Returns the assistant message content string.
151
+ Falls back to DEFAULT_MODEL if the requested model port is unavailable.
152
+ """
153
+ return _call_with_history(
154
+ model, system,
155
+ [{"role": "user", "content": user}],
156
+ max_tokens=max_tokens,
157
+ json_mode=json_mode,
158
+ temperature=temperature,
159
+ )
dukaan_saathi/integrations/llamacpp_receipt.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ llamacpp_receipt.py — Receipt text → structured rows via fine-tuned Llama-3.2-3B on llama.cpp.
3
+
4
+ Calls the fine-tuned model on port 8082 to parse OCR receipt text into structured line items.
5
+ Returns the same (rows, trace) signature as parsers.receipt_text.parse_receipt_text so it
6
+ can be dropped in as an alternative backend.
7
+
8
+ Falls back to the deterministic parser if the LLM server is unavailable or returns bad JSON.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import logging
15
+ from typing import Any
16
+
17
+ from dukaan_saathi.storage import find_product
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ SYSTEM_PROMPT = (
22
+ "You are a receipt parser for an Indian convenience store. "
23
+ "Extract all line items from the receipt text. "
24
+ "Return ONLY valid JSON with this structure: "
25
+ '{"supplier": "...", "invoice_no": "...", "date": "YYYY-MM-DD", '
26
+ '"items": [{"product_raw": "...", "qty_cases": 0, "qty_units": 0, '
27
+ '"unit_cost": 0.0, "total": 0.0}], '
28
+ '"subtotal": 0.0, "discount": 0.0, "gst": 0.0, "net_total": 0.0}. '
29
+ "No markdown, no explanation."
30
+ )
31
+
32
+
33
+ def _llm_item_to_row(item: dict[str, Any], supplier: str, document_type: str) -> dict[str, Any]:
34
+ product_raw = str(item.get("product_raw", "")).strip()
35
+ qty_units = int(item.get("qty_units") or item.get("qty_cases") or 0)
36
+ unit_price = float(item.get("unit_cost") or 0) or None
37
+ total_price = float(item.get("total") or 0) or None
38
+
39
+ matched = find_product(product_raw)
40
+ matched_product_id = matched["id"] if matched else ""
41
+ matched_product_name = matched["name"] if matched else ""
42
+
43
+ warning_parts = []
44
+ if not matched:
45
+ warning_parts.append("No catalog match; owner must map or skip.")
46
+ if item.get("needs_review"):
47
+ warning_parts.append("Flagged for review by parser.")
48
+
49
+ return {
50
+ "apply": bool(matched),
51
+ "document_type": document_type,
52
+ "supplier": supplier,
53
+ "product_raw": product_raw,
54
+ "matched_product_id": matched_product_id,
55
+ "matched_product_name": matched_product_name,
56
+ "quantity_raw": str(qty_units),
57
+ "quantity": qty_units,
58
+ "unit_price": unit_price,
59
+ "total_price": total_price,
60
+ "confidence": 0.85 if matched else 0.5,
61
+ "warning": " | ".join(warning_parts),
62
+ }
63
+
64
+
65
+ def parse_receipt_via_llm(raw_text: str) -> tuple[list[dict[str, Any]], list[str]]:
66
+ """
67
+ Parse receipt OCR text using the fine-tuned Llama-3.2-3B model via llama.cpp.
68
+ Falls back to the deterministic parser on any failure.
69
+ """
70
+ from dukaan_saathi.integrations.llamacpp_llm import call_llm
71
+ from dukaan_saathi.parsers.receipt_text import parse_receipt_text, detect_supplier, detect_document_type
72
+
73
+ trace: list[str] = ["[llamacpp] Calling fine-tuned Llama-3.2-3B for receipt parsing"]
74
+
75
+ try:
76
+ response_text = call_llm(
77
+ model="llama-3.2-3b-receipt",
78
+ system=SYSTEM_PROMPT,
79
+ user=raw_text,
80
+ max_tokens=768,
81
+ json_mode=True,
82
+ temperature=0.1,
83
+ )
84
+
85
+ if '"error"' in response_text:
86
+ raise ValueError(f"LLM returned error: {response_text}")
87
+
88
+ parsed = json.loads(response_text)
89
+ items = parsed.get("items", [])
90
+
91
+ if not items:
92
+ raise ValueError("LLM returned zero items")
93
+
94
+ supplier = parsed.get("supplier", detect_supplier(raw_text))
95
+ document_type = detect_document_type(raw_text)
96
+
97
+ trace.append(f"[llamacpp] Parsed supplier: {supplier}")
98
+ trace.append(f"[llamacpp] Extracted {len(items)} items from LLM response")
99
+
100
+ rows = []
101
+ for item in items:
102
+ row = _llm_item_to_row(item, supplier, document_type)
103
+ rows.append(row)
104
+ if row["matched_product_name"]:
105
+ trace.append(f"[llamacpp] Matched '{row['product_raw']}' → {row['matched_product_name']}")
106
+ else:
107
+ trace.append(f"[llamacpp] Needs owner review: '{row['product_raw']}'")
108
+
109
+ trace.append(f"[llamacpp] Extracted {len(rows)} candidate line items")
110
+ return rows, trace
111
+
112
+ except Exception as exc:
113
+ logger.warning(f"llamacpp_receipt falling back to deterministic parser: {exc}")
114
+ trace.append(f"[llamacpp] Fallback to deterministic parser: {exc}")
115
+ fallback_rows, fallback_trace = parse_receipt_text(raw_text)
116
+ return fallback_rows, trace + fallback_trace
dukaan_saathi/integrations/modal_receipt.py ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import requests
8
+
9
+ from dukaan_saathi.integrations.vision import (
10
+ VisionBackendName,
11
+ VisionExtractionResult,
12
+ VisionRequest,
13
+ VisionRow,
14
+ )
15
+ from dukaan_saathi.parsers.receipt_text import parse_receipt_text
16
+
17
+
18
+ BACKEND_NAME: VisionBackendName = "modal"
19
+ UNKNOWN_MODEL = "unknown model"
20
+
21
+
22
+ def _payload_text(payload: dict[str, Any]) -> str:
23
+ return str(payload.get("raw_text") or payload.get("text") or "")
24
+
25
+
26
+ def _payload_latency(payload: dict[str, Any]) -> float | None:
27
+ value = payload.get("latency_seconds")
28
+ if isinstance(value, int | float):
29
+ return float(value)
30
+ return None
31
+
32
+
33
+ def _coerce_rows(value: Any) -> list[VisionRow]:
34
+ if not isinstance(value, list):
35
+ return []
36
+ return [row for row in value if isinstance(row, dict)]
37
+
38
+
39
+ def _result_from_modal_payload(payload: dict[str, Any], trace: list[str]) -> VisionExtractionResult:
40
+ model_name = str(payload.get("model") or UNKNOWN_MODEL)
41
+ raw_text = _payload_text(payload)
42
+ rows = _coerce_rows(payload.get("rows"))
43
+ latency_seconds = _payload_latency(payload)
44
+
45
+ return VisionExtractionResult(
46
+ backend_name=BACKEND_NAME,
47
+ model_name=model_name,
48
+ raw_text=raw_text,
49
+ rows=rows,
50
+ latency_seconds=latency_seconds,
51
+ trace_messages=trace,
52
+ )
53
+
54
+
55
+ def _extract_receipt_result_with_modal(image_path: Any) -> VisionExtractionResult:
56
+ trace: list[str] = ["Starting receipt image extraction via Modal"]
57
+
58
+ endpoint = (os.getenv("MODAL_RECEIPT_ENDPOINT") or os.getenv("MINICPM_RECEIPT_ENDPOINT") or "").strip()
59
+ if not endpoint:
60
+ return VisionExtractionResult(
61
+ backend_name=BACKEND_NAME,
62
+ model_name=UNKNOWN_MODEL,
63
+ trace_messages=[
64
+ "MODAL_RECEIPT_ENDPOINT or MINICPM_RECEIPT_ENDPOINT is not set.",
65
+ "Model endpoint is not connected yet.",
66
+ "Use pasted/sample receipt text for the MVP path.",
67
+ ],
68
+ )
69
+
70
+ if not image_path:
71
+ return VisionExtractionResult(
72
+ backend_name=BACKEND_NAME,
73
+ model_name=UNKNOWN_MODEL,
74
+ trace_messages=["No receipt image provided."],
75
+ )
76
+
77
+ request = VisionRequest(image_path=Path(str(image_path)))
78
+ if not request.image_path.exists():
79
+ return VisionExtractionResult(
80
+ backend_name=BACKEND_NAME,
81
+ model_name=UNKNOWN_MODEL,
82
+ trace_messages=[f"Receipt image path does not exist: {request.image_path}"],
83
+ )
84
+
85
+ try:
86
+ with request.image_path.open("rb") as f:
87
+ response = requests.post(
88
+ endpoint,
89
+ files={"image": (request.image_path.name, f, "image/jpeg")},
90
+ timeout=180,
91
+ )
92
+ response.raise_for_status()
93
+ except requests.RequestException as exc:
94
+ return VisionExtractionResult(
95
+ backend_name=BACKEND_NAME,
96
+ model_name=UNKNOWN_MODEL,
97
+ trace_messages=[f"Modal request failed: {exc}"],
98
+ )
99
+
100
+ try:
101
+ payload = response.json()
102
+ except ValueError:
103
+ return VisionExtractionResult(
104
+ backend_name=BACKEND_NAME,
105
+ model_name=UNKNOWN_MODEL,
106
+ trace_messages=["Modal endpoint did not return valid JSON."],
107
+ )
108
+
109
+ if not isinstance(payload, dict):
110
+ return VisionExtractionResult(
111
+ backend_name=BACKEND_NAME,
112
+ model_name=UNKNOWN_MODEL,
113
+ trace_messages=["Modal endpoint JSON was not an object."],
114
+ )
115
+
116
+ return _result_from_modal_payload(payload, trace)
117
+
118
+
119
+ def extract_receipt_with_modal(image_path: Any) -> tuple[list[dict], list[str]]:
120
+ result = _extract_receipt_result_with_modal(image_path)
121
+ trace = list(result.trace_messages)
122
+
123
+ if result.raw_text.strip():
124
+ latency = (
125
+ f" in {result.latency_seconds:.2f}s"
126
+ if result.latency_seconds is not None
127
+ else ""
128
+ )
129
+ trace.append(f"Modal returned raw text using {result.model_name}{latency}")
130
+ rows, parser_trace = parse_receipt_text(result.raw_text)
131
+ trace.extend(parser_trace)
132
+ return rows, trace
133
+
134
+ if result.rows:
135
+ trace.append(f"Modal returned {len(result.rows)} structured rows")
136
+ return result.rows, trace
137
+
138
+ return [], trace + [
139
+ "Modal endpoint returned no raw_text or rows.",
140
+ ]
dukaan_saathi/integrations/modal_receipt_llm.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import hashlib
6
+ from typing import Any
7
+
8
+ import requests
9
+
10
+ from dukaan_saathi.integrations.llamacpp_receipt import _llm_item_to_row
11
+ from dukaan_saathi.parsers.receipt_text import (
12
+ detect_document_type,
13
+ detect_supplier,
14
+ parse_receipt_text,
15
+ )
16
+ from dukaan_saathi.traceability import new_run_id, utc_now_iso, write_manifest
17
+
18
+
19
+ def _text_sha256(value: str) -> str:
20
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
21
+
22
+
23
+ def _extract_json_payload(payload: dict[str, Any]) -> dict[str, Any]:
24
+ value = payload.get("parsed") or payload.get("raw_json") or payload.get("text") or ""
25
+ if isinstance(value, dict):
26
+ return value
27
+ if not isinstance(value, str):
28
+ raise ValueError("Modal parser response did not include JSON text")
29
+ return json.loads(value)
30
+
31
+
32
+ def parse_receipt_with_modal_llm(raw_text: str) -> tuple[list[dict[str, Any]], list[str]]:
33
+ trace: list[str] = ["[modal_llm] Calling Modal receipt parser endpoint"]
34
+ run_id = new_run_id("receipt-inference")
35
+ started_at = utc_now_iso()
36
+ endpoint = (
37
+ os.getenv("MODAL_RECEIPT_LLM_ENDPOINT")
38
+ or os.getenv("MODAL_RECEIPT_PARSER_ENDPOINT")
39
+ or ""
40
+ ).strip()
41
+
42
+ if not endpoint:
43
+ rows, fallback_trace = parse_receipt_text(raw_text)
44
+ manifest_path = write_manifest({
45
+ "run_id": run_id,
46
+ "kind": "receipt-inference",
47
+ "status": "fallback",
48
+ "started_at": started_at,
49
+ "ended_at": utc_now_iso(),
50
+ "metadata": {
51
+ "backend": "modal_llm",
52
+ "fallback_backend": "deterministic",
53
+ "reason": "missing_endpoint",
54
+ "raw_text_sha256": _text_sha256(raw_text),
55
+ "raw_text_chars": len(raw_text),
56
+ "rows": len(rows),
57
+ },
58
+ })
59
+ return rows, trace + [
60
+ "[modal_llm] MODAL_RECEIPT_LLM_ENDPOINT is not set; using deterministic parser",
61
+ f"[trace] Wrote inference manifest: {manifest_path}",
62
+ *fallback_trace,
63
+ ]
64
+
65
+ try:
66
+ response = requests.post(
67
+ endpoint,
68
+ json={"raw_text": raw_text},
69
+ timeout=180,
70
+ )
71
+ response.raise_for_status()
72
+ payload = response.json()
73
+ if not isinstance(payload, dict):
74
+ raise ValueError("Modal parser response JSON was not an object")
75
+
76
+ parsed = _extract_json_payload(payload)
77
+ items = parsed.get("items") or []
78
+ if not items:
79
+ raise ValueError("Modal parser returned zero items")
80
+
81
+ supplier = parsed.get("supplier") or detect_supplier(raw_text)
82
+ document_type = detect_document_type(raw_text)
83
+ rows = [
84
+ _llm_item_to_row(item, supplier=supplier, document_type=document_type)
85
+ for item in items
86
+ if isinstance(item, dict)
87
+ ]
88
+ if not rows:
89
+ raise ValueError("Modal parser returned no valid item objects")
90
+
91
+ model = str(payload.get("model") or "unknown")
92
+ latency = payload.get("latency_seconds")
93
+ latency_note = f" in {latency:.2f}s" if isinstance(latency, int | float) else ""
94
+ manifest_path = write_manifest({
95
+ "run_id": run_id,
96
+ "kind": "receipt-inference",
97
+ "status": "succeeded",
98
+ "started_at": started_at,
99
+ "ended_at": utc_now_iso(),
100
+ "metadata": {
101
+ "backend": "modal_llm",
102
+ "endpoint": endpoint,
103
+ "model": model,
104
+ "latency_seconds": latency,
105
+ "raw_text_sha256": _text_sha256(raw_text),
106
+ "raw_text_chars": len(raw_text),
107
+ "parsed_items": len(items),
108
+ "editable_rows": len(rows),
109
+ },
110
+ })
111
+ trace.append(f"[modal_llm] Parsed {len(rows)} rows with {model}{latency_note}")
112
+ trace.append(f"[trace] Wrote inference manifest: {manifest_path}")
113
+ return rows, trace
114
+
115
+ except Exception as exc:
116
+ rows, fallback_trace = parse_receipt_text(raw_text)
117
+ manifest_path = write_manifest({
118
+ "run_id": run_id,
119
+ "kind": "receipt-inference",
120
+ "status": "fallback",
121
+ "started_at": started_at,
122
+ "ended_at": utc_now_iso(),
123
+ "metadata": {
124
+ "backend": "modal_llm",
125
+ "fallback_backend": "deterministic",
126
+ "endpoint": endpoint,
127
+ "raw_text_sha256": _text_sha256(raw_text),
128
+ "raw_text_chars": len(raw_text),
129
+ "rows": len(rows),
130
+ },
131
+ "error": str(exc),
132
+ })
133
+ return rows, trace + [
134
+ f"[modal_llm] Fallback to deterministic parser: {exc}",
135
+ f"[trace] Wrote inference manifest: {manifest_path}",
136
+ *fallback_trace,
137
+ ]
dukaan_saathi/integrations/speech.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ import requests
7
+
8
+
9
+ def transcribe_audio(audio_path: str | None) -> tuple[str, list[str]]:
10
+ """
11
+ Speech-to-text client for correction commands.
12
+
13
+ Contract:
14
+ - input: audio file path from Gradio
15
+ - output: transcript string + trace
16
+
17
+ Safety boundary:
18
+ - This only returns text.
19
+ - It does not apply corrections.
20
+ - It does not approve inventory updates.
21
+ """
22
+
23
+ trace: list[str] = []
24
+
25
+ if not audio_path:
26
+ return "", ["No audio provided."]
27
+
28
+ path = Path(audio_path)
29
+
30
+ if not path.exists():
31
+ return "", [f"Audio file not found: {audio_path}"]
32
+
33
+ endpoint = (
34
+ os.getenv("MODAL_SPEECH_ENDPOINT")
35
+ or os.getenv("SPEECH_ASR_ENDPOINT")
36
+ or ""
37
+ ).strip()
38
+
39
+ if not endpoint:
40
+ return "", [
41
+ "Speech ASR endpoint is not configured.",
42
+ "Run scripts/modal_deploy.sh modal_apps/speech_asr_service.py to generate it.",
43
+ "Typed correction still works.",
44
+ ]
45
+
46
+ trace.append(f"Sending audio to ASR endpoint: {endpoint}")
47
+ trace.append(f"Audio file: {path.name}")
48
+
49
+ try:
50
+ with path.open("rb") as audio_file:
51
+ response = requests.post(
52
+ endpoint,
53
+ files={
54
+ "audio": (
55
+ path.name,
56
+ audio_file,
57
+ "application/octet-stream",
58
+ )
59
+ },
60
+ timeout=180,
61
+ )
62
+
63
+ if response.status_code >= 400:
64
+ return "", [
65
+ *trace,
66
+ f"ASR request failed with HTTP {response.status_code}.",
67
+ response.text[:500],
68
+ ]
69
+
70
+ payload = response.json()
71
+ transcript = str(payload.get("text") or "").strip()
72
+
73
+ if not transcript:
74
+ error = payload.get("error")
75
+ if error:
76
+ trace.append(f"ASR returned no transcript: {error}")
77
+ else:
78
+ trace.append("ASR returned no transcript.")
79
+ return "", trace
80
+
81
+ trace.append(f"ASR model: {payload.get('model', 'unknown')}")
82
+ trace.append(f"Transcript: {transcript}")
83
+
84
+ return transcript, trace
85
+
86
+ except requests.RequestException as exc:
87
+ return "", [
88
+ *trace,
89
+ f"ASR request error: {exc}",
90
+ "Typed correction still works.",
91
+ ]
92
+ except ValueError as exc:
93
+ return "", [
94
+ *trace,
95
+ f"Could not parse ASR response JSON: {exc}",
96
+ "Typed correction still works.",
97
+ ]
dukaan_saathi/integrations/vision.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Literal, Protocol, TypeAlias
6
+
7
+
8
+ VisionBackendName: TypeAlias = Literal["modal"]
9
+ VisionModelName: TypeAlias = str
10
+ VisionTrace: TypeAlias = list[str]
11
+ VisionRow: TypeAlias = dict[str, Any]
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class VisionRequest:
16
+ image_path: Path
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class VisionExtractionResult:
21
+ backend_name: VisionBackendName
22
+ model_name: VisionModelName
23
+ raw_text: str = ""
24
+ rows: list[VisionRow] = field(default_factory=list)
25
+ latency_seconds: float | None = None
26
+ trace_messages: VisionTrace = field(default_factory=list)
27
+
28
+
29
+ class ReceiptVisionBackend(Protocol):
30
+ backend_name: VisionBackendName
31
+
32
+ def extract_receipt(self, request: VisionRequest) -> VisionExtractionResult:
33
+ ...
dukaan_saathi/parsers/__init__.py ADDED
File without changes
dukaan_saathi/parsers/receipt_correction.py ADDED
@@ -0,0 +1,234 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from dukaan_saathi.storage import find_product
7
+
8
+
9
+ NO_MATCH_WARNING = "No catalog match; owner must map or skip."
10
+ OWNER_SKIP_WARNING = "Skipped by owner."
11
+
12
+ ORDINAL_TO_INDEX = {
13
+ "first": 0,
14
+ "1st": 0,
15
+ "second": 1,
16
+ "2nd": 1,
17
+ "third": 2,
18
+ "3rd": 2,
19
+ }
20
+
21
+
22
+ def _rows_to_records(rows: Any) -> list[dict[str, Any]]:
23
+ if rows is None:
24
+ return []
25
+
26
+ if hasattr(rows, "to_dict"):
27
+ return [dict(row) for row in rows.to_dict(orient="records")]
28
+
29
+ if isinstance(rows, dict) and "headers" in rows and "data" in rows:
30
+ headers = rows.get("headers") or []
31
+ return [dict(zip(headers, values)) for values in rows.get("data") or []]
32
+
33
+ return [dict(row) for row in rows]
34
+
35
+
36
+ def _split_commands(command_text: str) -> list[str]:
37
+ text = command_text or ""
38
+
39
+ # Supports: "first one Parle bulk and second one Bingo"
40
+ text = re.sub(
41
+ r"\s+(?:and|then)\s+(?=(?:first|1st|second|2nd|third|3rd|row\s*\d+|skip|quantity|qty)\b)",
42
+ ", ",
43
+ text,
44
+ flags=re.I,
45
+ )
46
+
47
+ return [part.strip() for part in re.split(r"[,;\n]+", text) if part.strip()]
48
+
49
+
50
+ def _parse_row_index(command: str) -> int | None:
51
+ lower = command.lower()
52
+
53
+ row_match = re.search(r"\brow\s*(\d+)\b", lower)
54
+ if row_match:
55
+ return int(row_match.group(1)) - 1
56
+
57
+ for word, index in ORDINAL_TO_INDEX.items():
58
+ if re.search(rf"\b{re.escape(word)}\b", lower):
59
+ return index
60
+
61
+ return None
62
+
63
+
64
+ def _warning_parts(warning: Any) -> list[str]:
65
+ return [
66
+ part.strip()
67
+ for part in str(warning or "").split("|")
68
+ if part and part.strip()
69
+ ]
70
+
71
+
72
+ def _set_warning_parts(row: dict[str, Any], parts: list[str]) -> None:
73
+ row["warning"] = " | ".join(dict.fromkeys(parts))
74
+
75
+
76
+ def _append_warning(row: dict[str, Any], warning: str) -> None:
77
+ parts = _warning_parts(row.get("warning"))
78
+ if warning not in parts:
79
+ parts.append(warning)
80
+ _set_warning_parts(row, parts)
81
+
82
+
83
+ def _remove_owner_resolution_warnings(row: dict[str, Any]) -> None:
84
+ parts = [
85
+ part
86
+ for part in _warning_parts(row.get("warning"))
87
+ if part not in {NO_MATCH_WARNING, OWNER_SKIP_WARNING}
88
+ ]
89
+ _set_warning_parts(row, parts)
90
+
91
+
92
+ def _as_int(value: Any) -> int | None:
93
+ try:
94
+ return int(float(value))
95
+ except (TypeError, ValueError):
96
+ return None
97
+
98
+
99
+ def _row_number(index: int) -> int:
100
+ return index + 1
101
+
102
+
103
+ def _extract_product_text(command: str) -> str:
104
+ match = re.match(
105
+ r"^\s*(?:row\s*\d+|first|1st|second|2nd|third|3rd)(?:\s+one)?\s+(?P<product>.+?)\s*$",
106
+ command,
107
+ flags=re.I,
108
+ )
109
+ if not match:
110
+ return ""
111
+
112
+ product = match.group("product").strip()
113
+ product = re.sub(r"^(?:is|as|to|product|item|name)\s+", "", product, flags=re.I)
114
+ return product.strip(" .:-")
115
+
116
+
117
+ def _apply_product_update(row: dict[str, Any], product_text: str) -> str:
118
+ row["product_raw"] = product_text
119
+
120
+ matched = find_product(product_text)
121
+
122
+ if matched:
123
+ row["matched_product_id"] = matched["id"]
124
+ row["matched_product_name"] = matched["name"]
125
+ row["apply"] = True
126
+ _remove_owner_resolution_warnings(row)
127
+
128
+ current_confidence = float(row.get("confidence") or 0)
129
+ row["confidence"] = round(max(current_confidence, 0.75), 2)
130
+
131
+ return f"matched {matched['name']}"
132
+
133
+ row["matched_product_id"] = ""
134
+ row["matched_product_name"] = ""
135
+ row["apply"] = False
136
+
137
+ current_confidence = float(row.get("confidence") or 0.55)
138
+ row["confidence"] = round(min(current_confidence, 0.55), 2)
139
+ _append_warning(row, NO_MATCH_WARNING)
140
+
141
+ return "no catalog match; apply=False"
142
+
143
+
144
+ def _apply_skip(row: dict[str, Any]) -> None:
145
+ row["apply"] = False
146
+ _append_warning(row, OWNER_SKIP_WARNING)
147
+
148
+
149
+ def _apply_quantity_update(row: dict[str, Any], quantity: int) -> None:
150
+ row["quantity"] = quantity
151
+ row["quantity_raw"] = str(quantity)
152
+
153
+
154
+ def _parse_quantity_value(command: str, row_index: int | None) -> int | None:
155
+ numbers = re.findall(r"\d+(?:\.\d+)?", command)
156
+
157
+ if not numbers:
158
+ return None
159
+
160
+ # For "quantity row 1 is 4", the last number is the desired quantity.
161
+ if row_index is not None and len(numbers) == 1:
162
+ only_number = _as_int(numbers[0])
163
+ if only_number == _row_number(row_index):
164
+ return None
165
+
166
+ return _as_int(numbers[-1])
167
+
168
+
169
+ def apply_receipt_correction_command(
170
+ rows: Any,
171
+ command_text: str,
172
+ ) -> tuple[list[dict[str, Any]], list[str]]:
173
+ """
174
+ Apply phone-friendly owner corrections to parsed receipt rows.
175
+
176
+ Supported examples:
177
+ - first one Parle bulk
178
+ - second one Bingo
179
+ - row 1 Parle bulk
180
+ - row 2 Bingo
181
+ - skip row 2
182
+ - quantity row 1 is 4
183
+ """
184
+
185
+ records = _rows_to_records(rows)
186
+ trace: list[str] = []
187
+
188
+ if not records:
189
+ return records, ["No receipt rows to correct."]
190
+
191
+ commands = _split_commands(command_text)
192
+
193
+ if not commands:
194
+ return records, ["No correction command provided."]
195
+
196
+ for command in commands:
197
+ row_index = _parse_row_index(command)
198
+
199
+ if row_index is None:
200
+ trace.append(f"Could not identify row in correction: {command}")
201
+ continue
202
+
203
+ if row_index < 0 or row_index >= len(records):
204
+ trace.append(f"Row {_row_number(row_index)} is out of range: {command}")
205
+ continue
206
+
207
+ row = records[row_index]
208
+ lower = command.lower()
209
+
210
+ if re.search(r"\bskip\b", lower):
211
+ _apply_skip(row)
212
+ trace.append(f"Skipped row {_row_number(row_index)} by owner command.")
213
+ continue
214
+
215
+ if re.search(r"\b(?:quantity|qty)\b", lower):
216
+ quantity = _parse_quantity_value(command, row_index)
217
+ if quantity is None or quantity <= 0:
218
+ trace.append(f"Could not parse positive quantity for row {_row_number(row_index)}: {command}")
219
+ continue
220
+
221
+ _apply_quantity_update(row, quantity)
222
+ trace.append(f"Updated row {_row_number(row_index)} quantity to {quantity}.")
223
+ continue
224
+
225
+ product_text = _extract_product_text(command)
226
+
227
+ if not product_text:
228
+ trace.append(f"Could not parse product correction for row {_row_number(row_index)}: {command}")
229
+ continue
230
+
231
+ result = _apply_product_update(row, product_text)
232
+ trace.append(f"Updated row {_row_number(row_index)} product to '{product_text}'; {result}.")
233
+
234
+ return records, trace
dukaan_saathi/parsers/receipt_text.py ADDED
@@ -0,0 +1,375 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from dukaan_saathi.storage import find_product
7
+
8
+
9
+ SKIP_WORDS = {
10
+ "invoice",
11
+ "bill no",
12
+ "date",
13
+ "gstin",
14
+ "gst ",
15
+ "gross sales",
16
+ "cgst",
17
+ "sgst",
18
+ "net amount",
19
+ "total:",
20
+ "document type",
21
+ "phone",
22
+ "address",
23
+ }
24
+
25
+ def _looks_like_item_row(line: str) -> bool:
26
+ lower = line.lower()
27
+
28
+ # Pipe-separated invoice row:
29
+ # Product | 5/0 | MRP 10.00 | RATE 8.625 | GST 5% | NET 3105.000
30
+ if "|" in line and any(token in lower for token in ["rate", "net", "mrp"]):
31
+ return True
32
+
33
+ # Handwritten row:
34
+ # Bingo(C) 4 X 870 = 3480
35
+ if re.search(r"\d+(?:\.\d+)?\s*[xX*]\s*\d+(?:\.\d+)?", line):
36
+ return True
37
+
38
+ return False
39
+
40
+
41
+ def _should_skip_line(line: str) -> bool:
42
+ lower = line.lower()
43
+
44
+ if _looks_like_item_row(line):
45
+ return False
46
+
47
+ return any(word in lower for word in SKIP_WORDS)
48
+
49
+
50
+ def _to_float(value: str | None) -> float | None:
51
+ if value is None:
52
+ return None
53
+
54
+ cleaned = (
55
+ value.replace(",", "")
56
+ .replace("₹", "")
57
+ .replace("Rs.", "")
58
+ .replace("Rs", "")
59
+ .replace("%", "")
60
+ .strip()
61
+ )
62
+
63
+ try:
64
+ return float(cleaned)
65
+ except ValueError:
66
+ pass
67
+
68
+ # Model/OCR output often returns rates like "X2450" or "X8702".
69
+ number_match = re.search(r"\d+(?:\.\d+)?", cleaned)
70
+ if number_match:
71
+ return float(number_match.group(0))
72
+
73
+ return None
74
+
75
+
76
+ def _clean_product_name(value: str) -> str:
77
+ value = re.sub(r"[^A-Za-z0-9 .&()'-]", " ", value)
78
+ return re.sub(r"\s+", " ", value).strip(" -:.")
79
+
80
+
81
+ def _parse_quantity(value: str | None) -> tuple[int | None, str]:
82
+ if not value:
83
+ return None, ""
84
+
85
+ raw = value.strip()
86
+
87
+ # Printed invoice style: 5/0, 10/0
88
+ slash_match = re.match(r"^(\d+)\s*/\s*(\d+)$", raw)
89
+ if slash_match:
90
+ return int(slash_match.group(1)), raw
91
+
92
+ number_match = re.search(r"\d+(?:\.\d+)?", raw)
93
+ if number_match:
94
+ return int(float(number_match.group(0))), raw
95
+
96
+ return None, raw
97
+
98
+
99
+ def detect_supplier(raw_text: str) -> str:
100
+ text = raw_text.lower()
101
+
102
+ if "mahalakshmi marketing" in text:
103
+ return "Mahalakshmi Marketing"
104
+
105
+ if "venkateshwara" in text or "venkatesh" in text:
106
+ return "Sri Venkateshwara Marketing"
107
+
108
+ if "brundavan" in text or "bundavan" in text or "buns" in text:
109
+ return "Brundavan Buns"
110
+
111
+ lines = [line.strip() for line in raw_text.splitlines() if line.strip()]
112
+ for line in lines:
113
+ match = re.search(r"(supplier|vendor|from)\s*[:\-]\s*(.+)", line, re.I)
114
+ if match:
115
+ return match.group(2).strip()
116
+
117
+ for line in lines:
118
+ lower = line.lower()
119
+ if not any(word in lower for word in SKIP_WORDS) and not re.search(r"\d+\s*[xX*]\s*\d+", line):
120
+ return line[:80]
121
+
122
+ return "Unknown Supplier"
123
+
124
+
125
+ def detect_document_type(raw_text: str) -> str:
126
+ text = raw_text.lower()
127
+
128
+ explicit = re.search(r"document type\s*:\s*(.+)", raw_text, re.I)
129
+ if explicit:
130
+ return explicit.group(1).strip()
131
+
132
+ if "tax invoice" in text or "cash bill" in text or "gst" in text:
133
+ return "printed tax invoice"
134
+
135
+ if "tally" in text or "buns" in text:
136
+ return "handwritten tally note"
137
+
138
+ return "handwritten supplier bill"
139
+
140
+
141
+ def _build_row(
142
+ *,
143
+ document_type: str,
144
+ supplier: str,
145
+ product_raw: str,
146
+ quantity_raw: str,
147
+ quantity: int,
148
+ unit_price: float | None,
149
+ total_price: float | None,
150
+ confidence: float,
151
+ warning: str,
152
+ ) -> dict[str, Any]:
153
+ product_raw = _clean_product_name(product_raw)
154
+ matched = find_product(product_raw)
155
+
156
+ matched_product_id = matched["id"] if matched else ""
157
+ matched_product_name = matched["name"] if matched else ""
158
+
159
+ if not matched:
160
+ confidence = min(confidence, 0.55)
161
+ warning = (warning + " | " if warning else "") + "No catalog match; owner must map or skip."
162
+
163
+ return {
164
+ "apply": True if matched else False,
165
+ "document_type": document_type,
166
+ "supplier": supplier,
167
+ "product_raw": product_raw,
168
+ "matched_product_id": matched_product_id,
169
+ "matched_product_name": matched_product_name,
170
+ "quantity_raw": quantity_raw,
171
+ "quantity": quantity,
172
+ "unit_price": unit_price,
173
+ "total_price": total_price,
174
+ "confidence": round(confidence, 2),
175
+ "warning": warning,
176
+ }
177
+
178
+
179
+ def _validate_math(quantity: int, unit_price: float | None, total_price: float | None) -> tuple[float, str]:
180
+ if unit_price is None and total_price is None:
181
+ return 0.6, "Missing price; stock quantity only."
182
+
183
+ if unit_price is not None and total_price is None:
184
+ return 0.75, "Total inferred from quantity × unit price."
185
+
186
+ if unit_price is None and total_price is not None:
187
+ return 0.65, "Missing unit price; keeping receipt total."
188
+
189
+ expected = quantity * float(unit_price)
190
+ actual = float(total_price)
191
+
192
+ # Printed invoices often show per-piece rate but quantity is in cases,
193
+ # so we warn instead of failing when math does not match.
194
+ if abs(expected - actual) > max(1.0, 0.03 * max(expected, actual)):
195
+ return 0.62, f"Check math: qty × rate = {expected:.2f}, receipt says {actual:.2f}."
196
+
197
+ return 0.9, ""
198
+
199
+ def _looks_like_table_separator(parts: list[str]) -> bool:
200
+ return all(not part or set(part) <= {"-"} for part in parts)
201
+
202
+
203
+ def _looks_like_header_row(parts: list[str]) -> bool:
204
+ joined = " ".join(parts).lower()
205
+ return (
206
+ "particular" in joined
207
+ and "qty" in joined
208
+ and ("rate" in joined or "amount" in joined)
209
+ )
210
+
211
+
212
+ def _looks_like_serial(value: str) -> bool:
213
+ cleaned = value.strip()
214
+ return bool(re.match(r"^\d+\s*/?\s*\d*$", cleaned))
215
+
216
+ def _parse_pipe_row(line: str, document_type: str, supplier: str) -> dict[str, Any] | None:
217
+ if "|" not in line:
218
+ return None
219
+
220
+ parts = [part.strip() for part in line.split("|")]
221
+
222
+ if len(parts) < 2:
223
+ return None
224
+
225
+ if _looks_like_table_separator(parts) or _looks_like_header_row(parts):
226
+ return None
227
+
228
+ # Model-produced table style:
229
+ # serial | product | quantity | rate | amount
230
+ # Example:
231
+ # 5/ | Port | 1 | X2450 | 2450
232
+ # 10/ | Rs.g/c | 4 | X8702 | 3480
233
+ if len(parts) >= 5 and _looks_like_serial(parts[0]) and parts[1]:
234
+ product_raw = parts[1]
235
+ quantity, quantity_raw = _parse_quantity(parts[2])
236
+ unit_price = _to_float(parts[3])
237
+ total_price = _to_float(parts[4])
238
+
239
+ if quantity is None:
240
+ return None
241
+
242
+ confidence, warning = _validate_math(quantity, unit_price, total_price)
243
+
244
+ return _build_row(
245
+ document_type=document_type,
246
+ supplier=supplier,
247
+ product_raw=product_raw,
248
+ quantity_raw=quantity_raw,
249
+ quantity=quantity,
250
+ unit_price=unit_price,
251
+ total_price=total_price,
252
+ confidence=confidence,
253
+ warning=warning,
254
+ )
255
+
256
+ # Existing normalized style:
257
+ # product | quantity | rate | amount
258
+ product_raw = parts[0]
259
+ quantity, quantity_raw = _parse_quantity(parts[1])
260
+ if quantity is None:
261
+ return None
262
+
263
+ unit_price = None
264
+ total_price = None
265
+
266
+ for part in parts[2:]:
267
+ lower = part.lower()
268
+
269
+ if "rate" in lower:
270
+ unit_price = _to_float(part.split()[-1])
271
+ elif "net" in lower or "amount" in lower:
272
+ total_price = _to_float(part.split()[-1])
273
+ elif unit_price is None:
274
+ maybe = _to_float(part.split()[-1])
275
+ if maybe is not None:
276
+ unit_price = maybe
277
+ elif total_price is None:
278
+ maybe = _to_float(part.split()[-1])
279
+ if maybe is not None:
280
+ total_price = maybe
281
+
282
+ confidence, warning = _validate_math(quantity, unit_price, total_price)
283
+
284
+ return _build_row(
285
+ document_type=document_type,
286
+ supplier=supplier,
287
+ product_raw=product_raw,
288
+ quantity_raw=quantity_raw,
289
+ quantity=quantity,
290
+ unit_price=unit_price,
291
+ total_price=total_price,
292
+ confidence=confidence,
293
+ warning=warning,
294
+ )
295
+
296
+ def _parse_multiply_row(line: str, document_type: str, supplier: str) -> dict[str, Any] | None:
297
+ pattern = re.compile(
298
+ r"^(?P<product>.+?)\s+"
299
+ r"(?P<quantity>\d+(?:\.\d+)?)\s*[xX*]\s*"
300
+ r"(?P<unit_price>\d+(?:\.\d+)?)"
301
+ r"(?:\s*(?:=|rs\.?|inr)?\s*(?P<total_price>\d+(?:\.\d+)?))?\s*$",
302
+ re.I,
303
+ )
304
+
305
+ match = pattern.match(line)
306
+ if not match:
307
+ return None
308
+
309
+ product_raw = match.group("product")
310
+ quantity, quantity_raw = _parse_quantity(match.group("quantity"))
311
+ unit_price = _to_float(match.group("unit_price"))
312
+ total_price = _to_float(match.group("total_price"))
313
+
314
+ if quantity is None:
315
+ return None
316
+
317
+ if unit_price is not None and total_price is None:
318
+ total_price = quantity * unit_price
319
+
320
+ confidence, warning = _validate_math(quantity, unit_price, total_price)
321
+
322
+ return _build_row(
323
+ document_type=document_type,
324
+ supplier=supplier,
325
+ product_raw=product_raw,
326
+ quantity_raw=quantity_raw,
327
+ quantity=quantity,
328
+ unit_price=unit_price,
329
+ total_price=total_price,
330
+ confidence=confidence,
331
+ warning=warning,
332
+ )
333
+
334
+
335
+ def parse_receipt_text(raw_text: str) -> tuple[list[dict[str, Any]], list[str]]:
336
+ raw_text = raw_text or ""
337
+
338
+ supplier = detect_supplier(raw_text)
339
+ document_type = detect_document_type(raw_text)
340
+
341
+ trace: list[str] = [
342
+ f"Detected supplier: {supplier}",
343
+ f"Detected document type: {document_type}",
344
+ ]
345
+
346
+ lines = [line.strip() for line in raw_text.splitlines() if line.strip()]
347
+ trace.append(f"Read {len(lines)} non-empty lines")
348
+
349
+ rows: list[dict[str, Any]] = []
350
+
351
+ for line in lines:
352
+ if _should_skip_line(line):
353
+ continue
354
+
355
+ parsed = (
356
+ _parse_pipe_row(line, document_type, supplier)
357
+ or _parse_multiply_row(line, document_type, supplier)
358
+ )
359
+
360
+ if parsed is None:
361
+ trace.append(f"Skipped unparsed line: {line}")
362
+ continue
363
+
364
+ rows.append(parsed)
365
+
366
+ if parsed["matched_product_name"]:
367
+ trace.append(f"Matched '{parsed['product_raw']}' → {parsed['matched_product_name']}")
368
+ else:
369
+ trace.append(f"Needs owner review: '{parsed['product_raw']}'")
370
+
371
+ if parsed["warning"]:
372
+ trace.append(f"Warning for '{parsed['product_raw']}': {parsed['warning']}")
373
+
374
+ trace.append(f"Extracted {len(rows)} candidate line items")
375
+ return rows, trace
dukaan_saathi/parsers/stock_command.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from typing import Any
5
+
6
+ from dukaan_saathi.storage import find_product
7
+
8
+
9
+ OUT_OF_STOCK_MARKERS = [
10
+ "out",
11
+ "finished",
12
+ "empty",
13
+ "khatam",
14
+ "ayipoyindi",
15
+ "అయిపోయింది",
16
+ ]
17
+
18
+ LOW_STOCK_MARKERS = [
19
+ "low",
20
+ "less",
21
+ "తక్కువ",
22
+ ]
23
+
24
+ ADD_STOCK_MARKERS = [
25
+ "add",
26
+ "received",
27
+ "arrived",
28
+ "stock add",
29
+ "add cheyyi",
30
+ ]
31
+
32
+
33
+ def parse_stock_command(command: str) -> tuple[dict[str, Any], list[str]]:
34
+ command = (command or "").strip()
35
+ trace = [f"Received command: {command}"]
36
+
37
+ if not command:
38
+ return {"status": "error", "message": "No command provided."}, trace
39
+
40
+ product = find_product(command)
41
+ if product is None:
42
+ trace.append("No product matched.")
43
+ numbers = [int(n) for n in re.findall(r"\d+", command)]
44
+ stop_words = {"add", "set", "stock", "received", "arrived", "low", "out",
45
+ "finished", "to", "the", "a", "an", "kg", "g", "ml", "l",
46
+ "unit", "units", "piece", "pieces", "pack", "packs"}
47
+ words = [
48
+ w for raw in re.sub(r"\d+", "", command.lower()).split()
49
+ if (w := re.sub(r"[^a-z]", "", raw)) and w not in stop_words
50
+ ]
51
+ suggested_name = " ".join(words).title() if words else None
52
+ suggested_qty = numbers[-1] if numbers else None
53
+ return {
54
+ "status": "needs_review",
55
+ "message": "Could not match a known product.",
56
+ "raw_command": command,
57
+ "suggested_name": suggested_name,
58
+ "suggested_qty": suggested_qty,
59
+ }, trace
60
+
61
+ trace.append(f"Matched product: {product['name']}")
62
+
63
+ lower = command.lower()
64
+ numbers = [int(n) for n in re.findall(r"\d+", command)]
65
+
66
+ if any(marker in lower for marker in OUT_OF_STOCK_MARKERS) or "అయిపోయింది" in command:
67
+ trace.append("Detected intent: stockout")
68
+ trace.append("Proposed action: set stock to 0")
69
+ return {
70
+ "status": "pending_approval",
71
+ "type": "set_stock",
72
+ "product_id": product["id"],
73
+ "product_name": product["name"],
74
+ "new_stock": 0,
75
+ "reason": f"Owner said product is out of stock: {command}",
76
+ }, trace
77
+
78
+ if any(marker in lower for marker in ADD_STOCK_MARKERS):
79
+ qty = numbers[-1] if numbers else 1
80
+ trace.append("Detected intent: add stock")
81
+ trace.append(f"Proposed action: add {qty}")
82
+ return {
83
+ "status": "pending_approval",
84
+ "type": "add_stock",
85
+ "product_id": product["id"],
86
+ "product_name": product["name"],
87
+ "delta": qty,
88
+ "reason": f"Owner said stock arrived: {command}",
89
+ }, trace
90
+
91
+ if any(marker in lower for marker in LOW_STOCK_MARKERS):
92
+ trace.append("Detected intent: mark low stock")
93
+ trace.append("Proposed action: set stock to 1")
94
+ return {
95
+ "status": "pending_approval",
96
+ "type": "set_stock",
97
+ "product_id": product["id"],
98
+ "product_name": product["name"],
99
+ "new_stock": 1,
100
+ "reason": f"Owner said stock is low: {command}",
101
+ }, trace
102
+
103
+ if numbers:
104
+ qty = numbers[-1]
105
+ trace.append("Detected intent: set stock count")
106
+ trace.append(f"Proposed action: set stock to {qty}")
107
+ return {
108
+ "status": "pending_approval",
109
+ "type": "set_stock",
110
+ "product_id": product["id"],
111
+ "product_name": product["name"],
112
+ "new_stock": qty,
113
+ "reason": f"Owner provided stock count: {command}",
114
+ }, trace
115
+
116
+ trace.append("Product matched, but intent was unclear.")
117
+ return {
118
+ "status": "needs_review",
119
+ "message": "Product matched, but command intent was unclear.",
120
+ "product_id": product["id"],
121
+ "product_name": product["name"],
122
+ "raw_command": command,
123
+ }, trace
dukaan_saathi/schemas.py ADDED
File without changes