Commit ·
dae60e5
0
Parent(s):
Deploy to HF Space
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +22 -0
- .gitignore +240 -0
- .python-version +1 -0
- AGENTS.md +290 -0
- Dockerfile +24 -0
- README.md +653 -0
- app.py +706 -0
- archive/legacy/asr.py +26 -0
- archive/legacy/database.py +357 -0
- archive/legacy/finetune_receipt.py +184 -0
- archive/legacy/graph.py +103 -0
- archive/legacy/inventory_manager.py +112 -0
- archive/legacy/llm.py +161 -0
- archive/legacy/ocr.py +86 -0
- archive/legacy/orchestrator.py +87 -0
- archive/legacy/po_check.py +41 -0
- archive/legacy/receipt_parser.py +113 -0
- archive/legacy/reorder_agent.py +143 -0
- archive/legacy/reporting_agent.py +47 -0
- archive/legacy/state.py +35 -0
- archive/legacy/translate.py +75 -0
- archive/legacy/vector_store.py +167 -0
- benchmarks/benchmark_receipt_models.py +124 -0
- data/.gitkeep +0 -0
- data/finetune/receipt_examples.jsonl +10 -0
- docs/deployment_setup.md +269 -0
- docs/plan_half_baked_features.md +97 -0
- docs/plan_react_agent.md +110 -0
- docs/plan_voice_command_agent.md +120 -0
- dukaan_saathi/__init__.py +0 -0
- dukaan_saathi/agent/__init__.py +0 -0
- dukaan_saathi/agent/agent.py +96 -0
- dukaan_saathi/agent/react_agent.py +85 -0
- dukaan_saathi/agent/tools.py +191 -0
- dukaan_saathi/config.py +24 -0
- dukaan_saathi/integrations/__init__.py +0 -0
- dukaan_saathi/integrations/command_nlu.py +48 -0
- dukaan_saathi/integrations/hf_inference_receipt.py +107 -0
- dukaan_saathi/integrations/hub_traces.py +71 -0
- dukaan_saathi/integrations/llamacpp_llm.py +159 -0
- dukaan_saathi/integrations/llamacpp_receipt.py +116 -0
- dukaan_saathi/integrations/modal_receipt.py +140 -0
- dukaan_saathi/integrations/modal_receipt_llm.py +137 -0
- dukaan_saathi/integrations/speech.py +97 -0
- dukaan_saathi/integrations/vision.py +33 -0
- dukaan_saathi/parsers/__init__.py +0 -0
- dukaan_saathi/parsers/receipt_correction.py +234 -0
- dukaan_saathi/parsers/receipt_text.py +375 -0
- dukaan_saathi/parsers/stock_command.py +123 -0
- 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
|