Submit Small Shop Ledger to Build Small Hackathon
Browse files- .gitignore +11 -0
- .gitmessage +3 -0
- ARCHITECTURE.md +259 -0
- DEPLOYMENT.md +255 -0
- FIELD_NOTES.md +113 -0
- README.md +228 -6
- ROADMAP.md +276 -0
- UI_DESIGN.md +147 -0
- app.py +9 -0
- modal_app.py +166 -0
- requirements.txt +10 -0
- shop_ledger/__init__.py +5 -0
- shop_ledger/heuristics.py +158 -0
- shop_ledger/insights.py +1177 -0
- shop_ledger/llama_backend.py +208 -0
- shop_ledger/processor.py +225 -0
- shop_ledger/schema.py +88 -0
- shop_ledger/ui.py +1615 -0
- tests/__init__.py +1 -0
- tests/test_heuristics.py +27 -0
- tests/test_insights.py +230 -0
- tests/test_processor.py +44 -0
- tests/test_ui_input_choice.py +273 -0
.gitignore
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
.venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.pytest_cache/
|
| 6 |
+
.mypy_cache/
|
| 7 |
+
.ruff_cache/
|
| 8 |
+
*.gguf
|
| 9 |
+
*.csv
|
| 10 |
+
*.log
|
| 11 |
+
modal-volume/
|
.gitmessage
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Subject line
|
| 2 |
+
|
| 3 |
+
Co-authored-by: OpenAI Codex <codex@openai.com>
|
ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Architecture
|
| 2 |
+
|
| 3 |
+
Small Shop Ledger is a small-model Gradio app that turns messy shop
|
| 4 |
+
notes into structured ledger rows, insights, and follow-up actions.
|
| 5 |
+
|
| 6 |
+
## System Overview
|
| 7 |
+
|
| 8 |
+
```text
|
| 9 |
+
User text/audio/document
|
| 10 |
+
|
|
| 11 |
+
v
|
| 12 |
+
Gradio UI (`shop_ledger/ui.py`)
|
| 13 |
+
|
|
| 14 |
+
+--> local mode: `LedgerProcessor`
|
| 15 |
+
|
|
| 16 |
+
+--> Modal mode: `LedgerAgent().process.remote(...)`
|
| 17 |
+
|
|
| 18 |
+
v
|
| 19 |
+
`LedgerProcessor`
|
| 20 |
+
|
|
| 21 |
+
+--> llama.cpp backend (`LlamaLedgerBackend`)
|
| 22 |
+
|
|
| 23 |
+
+--> heuristic fallback
|
| 24 |
+
|
|
| 25 |
+
v
|
| 26 |
+
`LedgerResult`
|
| 27 |
+
|
|
| 28 |
+
v
|
| 29 |
+
Dashboard, ledger table, automation queue, CSV export
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
## Code Map
|
| 33 |
+
|
| 34 |
+
| File | Responsibility |
|
| 35 |
+
| --- | --- |
|
| 36 |
+
| `app.py` | Local Gradio entrypoint. |
|
| 37 |
+
| `modal_app.py` | Modal image, volume, GPU worker, ASGI app, model download, smoke tests. |
|
| 38 |
+
| `shop_ledger/ui.py` | Gradio Blocks UI, input selection, callbacks, CSV export. |
|
| 39 |
+
| `shop_ledger/schema.py` | Pydantic models for ledger entries and model results. |
|
| 40 |
+
| `shop_ledger/llama_backend.py` | llama.cpp prompt, model loading, JSON parsing. |
|
| 41 |
+
| `shop_ledger/processor.py` | Runtime mode selection and fallback handling. |
|
| 42 |
+
| `shop_ledger/heuristics.py` | Deterministic parser for mock/dev/fallback mode. |
|
| 43 |
+
| `shop_ledger/insights.py` | Dashboard metrics, risk flags, follow-up queue, breakdown tables. |
|
| 44 |
+
| `tests/` | Unit tests for extraction, processor fallback, input-choice behavior, and insights. |
|
| 45 |
+
|
| 46 |
+
## Data Flow
|
| 47 |
+
|
| 48 |
+
1. The user enters a written note, records/uploads a voice note, or uploads a
|
| 49 |
+
receipt/bill image or PDF.
|
| 50 |
+
2. If multiple inputs are present and `Auto` is selected, the UI asks the user
|
| 51 |
+
to choose which input to analyze.
|
| 52 |
+
3. Audio input is transcribed locally with `faster-whisper` when available.
|
| 53 |
+
4. Documents are prepared locally: PDFs are rendered into page images with
|
| 54 |
+
PyMuPDF, uploaded images are resized with Pillow, and both become base64
|
| 55 |
+
data URLs.
|
| 56 |
+
5. The chosen note text is sent to `LedgerProcessor`.
|
| 57 |
+
6. In Modal production, `LedgerProcessor` uses `LlamaLedgerBackend`.
|
| 58 |
+
7. `LlamaLedgerBackend` asks Gemma through llama.cpp to return strict JSON,
|
| 59 |
+
using multimodal `image_url` message parts when document images are present.
|
| 60 |
+
8. The result is validated by `LedgerResult` and `LedgerEntry`, then tagged
|
| 61 |
+
with the readable `LLAMA_MODEL_LABEL` when llama.cpp was used.
|
| 62 |
+
9. Rows are appended to Gradio state.
|
| 63 |
+
10. The app recomputes:
|
| 64 |
+
- ledger table
|
| 65 |
+
- dashboard metrics
|
| 66 |
+
- field intelligence
|
| 67 |
+
- dynamic insight graph plan
|
| 68 |
+
- daily shop-pulse brief
|
| 69 |
+
- local ledger question answer
|
| 70 |
+
- shop pulse timeline
|
| 71 |
+
- counterparty memory
|
| 72 |
+
- anomaly scan
|
| 73 |
+
- closing checklist
|
| 74 |
+
- Plotly insight figures
|
| 75 |
+
- automation queue
|
| 76 |
+
- review queue
|
| 77 |
+
- category and party tables
|
| 78 |
+
- CSV export
|
| 79 |
+
11. The analyzed input is cleared so the next note starts cleanly.
|
| 80 |
+
|
| 81 |
+
## Model Contract
|
| 82 |
+
|
| 83 |
+
The model must return JSON shaped like:
|
| 84 |
+
|
| 85 |
+
```json
|
| 86 |
+
{
|
| 87 |
+
"entries": [
|
| 88 |
+
{
|
| 89 |
+
"date": "YYYY-MM-DD or empty",
|
| 90 |
+
"direction": "expense|income|transfer|unknown",
|
| 91 |
+
"counterparty": "person or business",
|
| 92 |
+
"item": "what changed hands",
|
| 93 |
+
"quantity": "quantity if known",
|
| 94 |
+
"amount": 0,
|
| 95 |
+
"currency": "LKR",
|
| 96 |
+
"category": "inventory|utilities|rent|wages|transport|maintenance|sales|general expense|uncategorized",
|
| 97 |
+
"payment_status": "paid|due|partial|unknown",
|
| 98 |
+
"due_date": "",
|
| 99 |
+
"reminder": "short follow-up reminder or empty",
|
| 100 |
+
"confidence": 0.0,
|
| 101 |
+
"original_note": "source fragment"
|
| 102 |
+
}
|
| 103 |
+
],
|
| 104 |
+
"reminders": ["short reminders"],
|
| 105 |
+
"questions": ["only ask if an amount, person, or due date is unclear"],
|
| 106 |
+
"cleaned_note": "normalized note"
|
| 107 |
+
}
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
The schema intentionally tolerates `null` for text fields by converting it to an
|
| 111 |
+
empty string. This prevents valid model intent from failing because of minor
|
| 112 |
+
JSON style differences.
|
| 113 |
+
|
| 114 |
+
## Fallback Design
|
| 115 |
+
|
| 116 |
+
The app keeps a deterministic heuristic parser for three reasons:
|
| 117 |
+
|
| 118 |
+
1. Local UI development should work without downloading a 12B model.
|
| 119 |
+
2. The live demo should never go completely blank if model loading fails.
|
| 120 |
+
3. Tests can verify app behavior quickly.
|
| 121 |
+
|
| 122 |
+
Fallback is visible. If llama.cpp fails, `model_used` becomes something like:
|
| 123 |
+
|
| 124 |
+
```text
|
| 125 |
+
heuristic fallback (ValidationError)
|
| 126 |
+
```
|
| 127 |
+
|
| 128 |
+
The exception details are added to `questions` so the UI and smoke tests expose
|
| 129 |
+
the reason.
|
| 130 |
+
|
| 131 |
+
## Insights Engine
|
| 132 |
+
|
| 133 |
+
`shop_ledger/insights.py` is pure Python and deterministic. It computes:
|
| 134 |
+
|
| 135 |
+
- net cash
|
| 136 |
+
- paid income
|
| 137 |
+
- paid expenses
|
| 138 |
+
- due income
|
| 139 |
+
- due expenses
|
| 140 |
+
- open follow-ups
|
| 141 |
+
- average extraction confidence
|
| 142 |
+
- top categories
|
| 143 |
+
- top parties
|
| 144 |
+
- high-value due risk flags
|
| 145 |
+
- low-confidence risk flags
|
| 146 |
+
- chart plan selection
|
| 147 |
+
- daily brief generation with Gemma or local fallback
|
| 148 |
+
- Ask My Ledger answers from structured rows with Gemma or local fallback
|
| 149 |
+
- voice questions for Ask My Ledger
|
| 150 |
+
- command palette actions
|
| 151 |
+
- counterparty memory cards
|
| 152 |
+
- anomaly detection
|
| 153 |
+
- daily closing checklist
|
| 154 |
+
- timeline events and pulse chart
|
| 155 |
+
- Plotly figures for due radar, spend pressure, cashflow, confidence review,
|
| 156 |
+
category mix, and party exposure
|
| 157 |
+
- follow-up queue with cadence and scripts
|
| 158 |
+
- reply studio variants for polite, friendly, and firm reminders
|
| 159 |
+
- review queue for low-confidence or incomplete rows
|
| 160 |
+
- daily field note
|
| 161 |
+
|
| 162 |
+
The chart planner is deterministic first. It asks what matters most right now:
|
| 163 |
+
unpaid money, expense pressure, cashflow timeline, review risk, or overall
|
| 164 |
+
category mix. Keeping insights separate from the Gradio UI makes the dashboard
|
| 165 |
+
testable and leaves room for a later local-LLM chart selector.
|
| 166 |
+
|
| 167 |
+
## UI Structure
|
| 168 |
+
|
| 169 |
+
The UI is a dark, custom-styled Gradio Blocks app organized as a small-shop
|
| 170 |
+
operating cockpit.
|
| 171 |
+
|
| 172 |
+
Top status strip:
|
| 173 |
+
|
| 174 |
+
- model status
|
| 175 |
+
- row count
|
| 176 |
+
|
| 177 |
+
Shop OS Cockpit:
|
| 178 |
+
|
| 179 |
+
- `Capture` rail: written note, voice note, document upload, input selector,
|
| 180 |
+
currency, add/clear actions, conflict notice, and examples.
|
| 181 |
+
- `Shop Pulse` center: live KPIs, chart composer, chart director, themed Plotly
|
| 182 |
+
graph wall, pulse timeline, and field intelligence.
|
| 183 |
+
- `Ledger Assistant` rail: running totals, reminders, Gemma daily brief, full
|
| 184 |
+
Ask My Ledger chat, voice questions, prompt suggestions, command palette, and
|
| 185 |
+
daily closing ritual.
|
| 186 |
+
|
| 187 |
+
Action Inbox:
|
| 188 |
+
|
| 189 |
+
- follow-up automation cards
|
| 190 |
+
- review desk cards
|
| 191 |
+
- anomaly lantern cards
|
| 192 |
+
- reply/review/anomaly tables inside a secondary accordion
|
| 193 |
+
|
| 194 |
+
Workbenches:
|
| 195 |
+
|
| 196 |
+
- `People`: counterparty memory, trust pulse, party totals, and next-message
|
| 197 |
+
suggestions.
|
| 198 |
+
- `Ledger Archive`: raw ledger rows, CSV export, category heatmap, closing
|
| 199 |
+
checklist table, and timeline event table.
|
| 200 |
+
|
| 201 |
+
See `UI_DESIGN.md` for the layout rationale, CSS hooks, and demo flow.
|
| 202 |
+
|
| 203 |
+
## Modal Production Path
|
| 204 |
+
|
| 205 |
+
The live Modal path is:
|
| 206 |
+
|
| 207 |
+
```text
|
| 208 |
+
fastapi_app
|
| 209 |
+
-> build_demo(process_fn=process_remote)
|
| 210 |
+
-> LedgerAgent().process.remote(note, currency)
|
| 211 |
+
-> LedgerProcessor.from_env()
|
| 212 |
+
-> LlamaLedgerBackend(...)
|
| 213 |
+
-> llama_cpp.Llama.create_chat_completion(...)
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
The model worker is configured with:
|
| 217 |
+
|
| 218 |
+
```text
|
| 219 |
+
gpu=A10
|
| 220 |
+
cpu=8
|
| 221 |
+
memory=32768
|
| 222 |
+
timeout=1800
|
| 223 |
+
LLAMA_N_GPU_LAYERS=-1
|
| 224 |
+
LLAMA_N_CTX=2048
|
| 225 |
+
LLAMA_MODEL_LABEL=unsloth/gemma-4-12b-it-GGUF / gemma-4-12b-it-UD-Q4_K_XL.gguf / llama.cpp
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
## Testing Strategy
|
| 229 |
+
|
| 230 |
+
Current tests cover:
|
| 231 |
+
|
| 232 |
+
- heuristic parsing
|
| 233 |
+
- processor fallback behavior
|
| 234 |
+
- text/audio input-choice rules
|
| 235 |
+
- field-clearing callback behavior
|
| 236 |
+
- dashboard metrics
|
| 237 |
+
- chart-plan selection
|
| 238 |
+
- Plotly figure generation
|
| 239 |
+
- follow-up queue priority
|
| 240 |
+
- reply studio message variants
|
| 241 |
+
- review queue generation
|
| 242 |
+
- risk flags
|
| 243 |
+
|
| 244 |
+
Run:
|
| 245 |
+
|
| 246 |
+
```bash
|
| 247 |
+
python3 -m unittest discover -s tests
|
| 248 |
+
python3 -m compileall shop_ledger app.py modal_app.py tests
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
## Known Constraints
|
| 252 |
+
|
| 253 |
+
- Gradio state is session-local. This is enough for the hackathon demo but not a
|
| 254 |
+
multi-user accounting product.
|
| 255 |
+
- CSV export is generated per session.
|
| 256 |
+
- Voice transcription uses local `faster-whisper` only when available.
|
| 257 |
+
- The app is not a replacement for accounting, tax, legal, or financial advice.
|
| 258 |
+
- The app should not store sensitive customer data without adding auth and
|
| 259 |
+
persistence controls.
|
DEPLOYMENT.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deployment And Modal Runbook
|
| 2 |
+
|
| 3 |
+
Small Shop Ledger runs as a Gradio web app on Modal. The UI is served by a
|
| 4 |
+
lightweight ASGI web function, while llama.cpp inference runs in a separate GPU
|
| 5 |
+
worker that keeps the GGUF model warm between requests.
|
| 6 |
+
|
| 7 |
+
## Production URLs
|
| 8 |
+
|
| 9 |
+
- Live app: <https://keshan--voice-notes-shop-ledger-fastapi-app.modal.run>
|
| 10 |
+
- Modal app name: `voice-notes-shop-ledger`
|
| 11 |
+
- Modal model volume: `voice-notes-shop-ledger-models`
|
| 12 |
+
- GitHub repo: <https://github.com/keshan/voice-notes-shop-ledger>
|
| 13 |
+
|
| 14 |
+
The Modal and GitHub slugs still use the original project identifier so the
|
| 15 |
+
existing deployment URL and model volume remain stable.
|
| 16 |
+
|
| 17 |
+
## Modal Architecture
|
| 18 |
+
|
| 19 |
+
`modal_app.py` defines four Modal objects:
|
| 20 |
+
|
| 21 |
+
| Object | Type | Purpose |
|
| 22 |
+
| --- | --- | --- |
|
| 23 |
+
| `download_model` | `@app.function` | Downloads the GGUF from Hugging Face into the persistent Modal volume. |
|
| 24 |
+
| `LedgerAgent` | `@app.cls` | GPU-backed llama.cpp worker used for extraction. |
|
| 25 |
+
| `smoke_test_model` | `@app.function` | Runs one extraction inside Modal and reports model/runtime metadata. |
|
| 26 |
+
| `fastapi_app` | `@app.function` + `@modal.asgi_app()` | Serves the Gradio UI and calls `LedgerAgent`. |
|
| 27 |
+
|
| 28 |
+
The web function and model worker are separated intentionally:
|
| 29 |
+
|
| 30 |
+
- The web layer stays responsive and cheap.
|
| 31 |
+
- The model worker gets GPU, CPU, and memory resources.
|
| 32 |
+
- The GGUF can stay loaded in a warm worker between Gradio requests.
|
| 33 |
+
- The UI can still render even if model inference is cold-starting.
|
| 34 |
+
|
| 35 |
+
## Runtime Choices
|
| 36 |
+
|
| 37 |
+
| Setting | Value | Why |
|
| 38 |
+
| --- | --- | --- |
|
| 39 |
+
| Base image | `nvidia/cuda:12.8.1-devel-ubuntu24.04` | CUDA-compatible runtime for llama.cpp GPU inference. |
|
| 40 |
+
| Python | `3.12` | Matches local development and Modal image setup. |
|
| 41 |
+
| GPU | Modal `A10` | Enough VRAM for a 12B-class quantized GGUF demo. |
|
| 42 |
+
| Model repo | `unsloth/gemma-4-12b-it-GGUF` | Gemma 4 12B GGUF distribution. |
|
| 43 |
+
| Model file | `gemma-4-12b-it-UD-Q4_K_XL.gguf` | Good quality/performance quant for the hackathon demo. |
|
| 44 |
+
| Runtime | `llama-cpp-python` CUDA wheel | Avoids source-building llama.cpp during deploy. |
|
| 45 |
+
| Charts | `plotly>=6.0,<7` | Renders dynamic Gradio `Plot` dashboards. |
|
| 46 |
+
| Documents | PyMuPDF + Pillow + Gemma vision | Renders PDFs/images locally, then sends data URLs to llama.cpp multimodal chat. |
|
| 47 |
+
| GPU layers | `LLAMA_N_GPU_LAYERS=-1` | Offload all possible layers to GPU. |
|
| 48 |
+
| Context | `LLAMA_N_CTX=2048` | Keeps ledger extraction responsive. |
|
| 49 |
+
|
| 50 |
+
The source-build path for CUDA llama.cpp was tested, but it failed during image
|
| 51 |
+
build because `libcuda.so.1` is only available on GPU runtime machines, not
|
| 52 |
+
during the Modal image build. The prebuilt CUDA wheel is therefore the reliable
|
| 53 |
+
deployment path for this app.
|
| 54 |
+
|
| 55 |
+
## First-Time Setup
|
| 56 |
+
|
| 57 |
+
Install and authenticate Modal:
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
pip install modal
|
| 61 |
+
modal setup
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
Download the model into the persistent Modal volume:
|
| 65 |
+
|
| 66 |
+
```bash
|
| 67 |
+
modal run modal_app.py::download_model
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
This downloads:
|
| 71 |
+
|
| 72 |
+
```text
|
| 73 |
+
repo: unsloth/gemma-4-12b-it-GGUF
|
| 74 |
+
file: gemma-4-12b-it-UD-Q4_K_XL.gguf
|
| 75 |
+
target: /models/model.gguf
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
You can confirm the volume contents:
|
| 79 |
+
|
| 80 |
+
```bash
|
| 81 |
+
modal volume ls voice-notes-shop-ledger-models
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Expected files include:
|
| 85 |
+
|
| 86 |
+
```text
|
| 87 |
+
model.gguf
|
| 88 |
+
gemma-4-12b-it-UD-Q4_K_XL.gguf
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
## Deploy
|
| 92 |
+
|
| 93 |
+
Stop the currently deployed app when you want a clean redeploy:
|
| 94 |
+
|
| 95 |
+
```bash
|
| 96 |
+
modal app stop voice-notes-shop-ledger
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
Deploy the app:
|
| 100 |
+
|
| 101 |
+
```bash
|
| 102 |
+
modal deploy modal_app.py
|
| 103 |
+
```
|
| 104 |
+
|
| 105 |
+
Modal prints the live `.modal.run` URL after deployment.
|
| 106 |
+
|
| 107 |
+
## Smoke Test
|
| 108 |
+
|
| 109 |
+
Run the Modal smoke test:
|
| 110 |
+
|
| 111 |
+
```bash
|
| 112 |
+
modal run modal_app.py::smoke
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
A healthy run should print a result shaped like:
|
| 116 |
+
|
| 117 |
+
```text
|
| 118 |
+
{
|
| 119 |
+
"model_used": "unsloth/gemma-4-12b-it-GGUF / gemma-4-12b-it-UD-Q4_K_XL.gguf / llama.cpp",
|
| 120 |
+
"entry_count": 2,
|
| 121 |
+
"amounts": [1200.0, 750.0],
|
| 122 |
+
"questions": [],
|
| 123 |
+
"gpu_type": "A10"
|
| 124 |
+
}
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
If `model_used` starts with `heuristic fallback`, the app is not using the
|
| 128 |
+
llama.cpp model for that request. Check the `questions` field; fallback errors
|
| 129 |
+
are deliberately surfaced there.
|
| 130 |
+
|
| 131 |
+
## Logs
|
| 132 |
+
|
| 133 |
+
View app logs:
|
| 134 |
+
|
| 135 |
+
```bash
|
| 136 |
+
modal app logs voice-notes-shop-ledger
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
Useful signals:
|
| 140 |
+
|
| 141 |
+
- `llama_context` lines mean llama.cpp loaded the GGUF.
|
| 142 |
+
- `model_used` showing `unsloth/gemma-4-12b-it-GGUF` means model extraction
|
| 143 |
+
succeeded and the UI is showing the human model label rather than the mounted
|
| 144 |
+
filename.
|
| 145 |
+
- `heuristic fallback (missing GGUF model)` means the volume does not contain
|
| 146 |
+
`/models/model.gguf`.
|
| 147 |
+
- `heuristic fallback (ValidationError)` usually means the model returned JSON
|
| 148 |
+
that did not match the schema.
|
| 149 |
+
- Blank charts usually mean Plotly is missing from the image or the deployed UI
|
| 150 |
+
has not been refreshed after a code push.
|
| 151 |
+
- Empty document extraction usually means the uploaded file type was unsupported
|
| 152 |
+
or multimodal llama.cpp inference failed.
|
| 153 |
+
|
| 154 |
+
## Local Development
|
| 155 |
+
|
| 156 |
+
Run without a model:
|
| 157 |
+
|
| 158 |
+
```bash
|
| 159 |
+
python app.py
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
This uses heuristic mode by default. It is useful for UI work and tests.
|
| 163 |
+
|
| 164 |
+
Run locally with a GGUF:
|
| 165 |
+
|
| 166 |
+
```bash
|
| 167 |
+
export LEDGER_MODEL_MODE=llama
|
| 168 |
+
export LLAMA_GGUF_PATH=/path/to/gemma-4-12b-it-UD-Q4_K_XL.gguf
|
| 169 |
+
export LLAMA_N_GPU_LAYERS=0
|
| 170 |
+
python app.py
|
| 171 |
+
```
|
| 172 |
+
|
| 173 |
+
On a laptop without GPU-compatible llama.cpp, keep `LLAMA_N_GPU_LAYERS=0`.
|
| 174 |
+
|
| 175 |
+
## Environment Variables
|
| 176 |
+
|
| 177 |
+
| Variable | Used by | Default on Modal | Purpose |
|
| 178 |
+
| --- | --- | --- | --- |
|
| 179 |
+
| `LEDGER_MODEL_MODE` | `LedgerProcessor` | `llama` | Selects `llama` or `mock` mode. |
|
| 180 |
+
| `LLAMA_GGUF_PATH` | `LlamaLedgerBackend` | `/models/model.gguf` | Path to the model file. |
|
| 181 |
+
| `LLAMA_MODEL_LABEL` | `LlamaLedgerBackend` | `unsloth/gemma-4-12b-it-GGUF / gemma-4-12b-it-UD-Q4_K_XL.gguf / llama.cpp` | Human-readable label shown in the UI and smoke tests. |
|
| 182 |
+
| `LLAMA_N_GPU_LAYERS` | `LlamaLedgerBackend` | `-1` | Number of layers to offload to GPU. |
|
| 183 |
+
| `LLAMA_N_CTX` | `LlamaLedgerBackend` | `2048` | llama.cpp context window. |
|
| 184 |
+
| `WHISPER_MODEL_SIZE` | `transcribe_audio` | `tiny` | Local faster-whisper model size for voice notes. |
|
| 185 |
+
|
| 186 |
+
## Operational Notes
|
| 187 |
+
|
| 188 |
+
- The app intentionally does not call cloud LLM APIs.
|
| 189 |
+
- Modal is infrastructure only; inference happens inside the deployed
|
| 190 |
+
llama.cpp worker.
|
| 191 |
+
- The `A10` worker may cold-start. The UI can load before the first model
|
| 192 |
+
request finishes.
|
| 193 |
+
- The Gradio web function uses `max_containers=1` so the UI state and queue are
|
| 194 |
+
easier to reason about during the demo.
|
| 195 |
+
- The model volume is persistent. Redeploying the app does not redownload the
|
| 196 |
+
GGUF unless you run `download_model` again.
|
| 197 |
+
|
| 198 |
+
## Troubleshooting
|
| 199 |
+
|
| 200 |
+
### The app says heuristics were used
|
| 201 |
+
|
| 202 |
+
Run:
|
| 203 |
+
|
| 204 |
+
```bash
|
| 205 |
+
modal run modal_app.py::smoke
|
| 206 |
+
```
|
| 207 |
+
|
| 208 |
+
Then check:
|
| 209 |
+
|
| 210 |
+
- Does `model_used` include `unsloth/gemma-4-12b-it-GGUF`?
|
| 211 |
+
- Does `modal volume ls voice-notes-shop-ledger-models` show `model.gguf`?
|
| 212 |
+
- Does the printed `questions` list include a schema or validation error?
|
| 213 |
+
|
| 214 |
+
### CPU inference is too slow
|
| 215 |
+
|
| 216 |
+
Make sure the deployed code has:
|
| 217 |
+
|
| 218 |
+
```python
|
| 219 |
+
gpu="A10"
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
on `LedgerAgent` and `smoke_test_model`, plus:
|
| 223 |
+
|
| 224 |
+
```text
|
| 225 |
+
LLAMA_N_GPU_LAYERS=-1
|
| 226 |
+
```
|
| 227 |
+
|
| 228 |
+
### CUDA wheel crashes
|
| 229 |
+
|
| 230 |
+
The deployed version currently uses the prebuilt CUDA `llama-cpp-python` wheel
|
| 231 |
+
from:
|
| 232 |
+
|
| 233 |
+
```text
|
| 234 |
+
https://abetlen.github.io/llama-cpp-python/whl/cu125
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
If that wheel becomes incompatible, the fallback plan is:
|
| 238 |
+
|
| 239 |
+
1. Try a different CUDA wheel index supported by `llama-cpp-python`.
|
| 240 |
+
2. Build from source inside an image with the CUDA driver stub library available
|
| 241 |
+
at link time.
|
| 242 |
+
3. Temporarily use CPU mode only for UI demos while debugging.
|
| 243 |
+
|
| 244 |
+
## Hackathon Submission Checklist
|
| 245 |
+
|
| 246 |
+
- Live Modal URL works.
|
| 247 |
+
- GitHub repo is public.
|
| 248 |
+
- Demo video shows text or voice note -> ledger -> dashboard -> automation
|
| 249 |
+
queue -> CSV export.
|
| 250 |
+
- Submission mentions:
|
| 251 |
+
- Gemma 4 12B parameter count.
|
| 252 |
+
- GGUF quantization.
|
| 253 |
+
- llama.cpp runtime.
|
| 254 |
+
- No external LLM API.
|
| 255 |
+
- Modal GPU deployment.
|
FIELD_NOTES.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Field Notes
|
| 2 |
+
|
| 3 |
+
## Person
|
| 4 |
+
|
| 5 |
+
Primary user to validate with before submission:
|
| 6 |
+
|
| 7 |
+
- Role: small shop owner, family-run store operator, market seller, or parent
|
| 8 |
+
tracking small household/shop dues.
|
| 9 |
+
- Current tool: notebook scraps, WhatsApp messages, voice notes, memory, or a
|
| 10 |
+
rough spreadsheet.
|
| 11 |
+
- Evidence to add: first name or pseudonym, shop/context, one quote, and one
|
| 12 |
+
screenshot or short clip of them trying the app.
|
| 13 |
+
|
| 14 |
+
## Problem
|
| 15 |
+
|
| 16 |
+
They track small purchases, supplier payments, customer dues, and reminders in
|
| 17 |
+
messy notes or voice messages. The problem is not accounting complexity; it is
|
| 18 |
+
the daily friction of turning rough notes into something they can review later.
|
| 19 |
+
|
| 20 |
+
## What We Built
|
| 21 |
+
|
| 22 |
+
Tagline: Messy notes in. Clear books by closing time.
|
| 23 |
+
|
| 24 |
+
Small Shop Ledger turns a note like:
|
| 25 |
+
|
| 26 |
+
```text
|
| 27 |
+
paid Ravi 1200 for rice bags, customer Nimal owes 750 for tea packets, remind me Friday
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
into ledger rows, totals, and follow-up reminders.
|
| 31 |
+
|
| 32 |
+
It also accepts receipts, bills, note photos, and PDFs. PDFs are rendered into
|
| 33 |
+
page images locally, and Gemma reads the document visually through llama.cpp's
|
| 34 |
+
multimodal chat input.
|
| 35 |
+
|
| 36 |
+
The current version is organized as a Shop OS Cockpit:
|
| 37 |
+
|
| 38 |
+
- Net cash, cash in, cash out, due amount, open follow-ups, and average
|
| 39 |
+
extraction confidence.
|
| 40 |
+
- A chart director that chooses the most useful graph for the current ledger:
|
| 41 |
+
dues, expenses, cashflow, confidence review, or category mix.
|
| 42 |
+
- A Gemma-generated daily shop pulse that summarizes cash position, pressure,
|
| 43 |
+
and the next follow-up from structured rows.
|
| 44 |
+
- Ask My Ledger for natural questions such as who owes the most or where cash
|
| 45 |
+
went, grounded in the structured rows.
|
| 46 |
+
- Voice questions for Ask My Ledger, so the shopkeeper can speak a question
|
| 47 |
+
after entering the day’s notes.
|
| 48 |
+
- A command palette for fast unpaid scans, WhatsApp follow-ups, risk checks,
|
| 49 |
+
cash summaries, and QuickBooks-style export planning.
|
| 50 |
+
- People Memory cards that summarize trust, due balances, usual categories, and
|
| 51 |
+
the next message per customer or supplier.
|
| 52 |
+
- Anomaly Lantern warnings for unusually large amounts, missing values,
|
| 53 |
+
high-value dues, repeat unpaid parties, and low-confidence rows.
|
| 54 |
+
- A Shop Pulse Timeline that turns raw rows into the story of the day.
|
| 55 |
+
- A Daily Closing Ritual that turns the ledger into an end-of-day checklist.
|
| 56 |
+
- Category and counterparty breakdowns.
|
| 57 |
+
- Risk flags for high-value dues and low-confidence extraction.
|
| 58 |
+
- Ready-to-send follow-up scripts with suggested cadence and three tones:
|
| 59 |
+
polite, friendly, and firm.
|
| 60 |
+
- A Review Desk for low-confidence or incomplete rows, with simple questions
|
| 61 |
+
the shopkeeper can answer before exporting.
|
| 62 |
+
|
| 63 |
+
## Small Model Fit
|
| 64 |
+
|
| 65 |
+
This is a structured extraction and rewriting task. A 12B-class model is enough
|
| 66 |
+
when the schema is narrow and the UI keeps the workflow grounded.
|
| 67 |
+
|
| 68 |
+
## What To Test With The User
|
| 69 |
+
|
| 70 |
+
- Can they enter a note faster than opening a spreadsheet?
|
| 71 |
+
- Do the extracted rows match what they meant?
|
| 72 |
+
- Are the categories useful?
|
| 73 |
+
- Do reminders feel helpful or annoying?
|
| 74 |
+
- Would they use the CSV export?
|
| 75 |
+
|
| 76 |
+
## Real-User Evidence Checklist
|
| 77 |
+
|
| 78 |
+
Before the final hackathon submission, capture:
|
| 79 |
+
|
| 80 |
+
- 3 raw notes from the user, with private details anonymized.
|
| 81 |
+
- 1 voice note or receipt/photo if they naturally use those.
|
| 82 |
+
- Extraction accuracy before correction: rows right, rows needing review, rows
|
| 83 |
+
wrong.
|
| 84 |
+
- Time-to-ledger compared with their current method.
|
| 85 |
+
- One thing they would actually use tomorrow.
|
| 86 |
+
- One thing they found confusing or unnecessary.
|
| 87 |
+
- One quote that explains why this helps in their words.
|
| 88 |
+
|
| 89 |
+
## Demo Video Beats
|
| 90 |
+
|
| 91 |
+
1. Show the messy note or voice note.
|
| 92 |
+
2. Click "Add to ledger".
|
| 93 |
+
3. Show the Shop Pulse center updating clean rows, totals, charts, and the
|
| 94 |
+
timeline without leaving the cockpit.
|
| 95 |
+
4. Generate the daily shop pulse and read the short practical summary.
|
| 96 |
+
5. Ask "Who owes me most?" and show the answer.
|
| 97 |
+
6. Ask the same question by voice.
|
| 98 |
+
7. Run the command palette for unpaid rows or QuickBooks export planning.
|
| 99 |
+
8. Show the Action Inbox with follow-ups, review rows, and anomalies.
|
| 100 |
+
9. Open People and show a counterparty profile.
|
| 101 |
+
10. Open the Ledger Archive and show the raw table, closing checklist, and CSV
|
| 102 |
+
export.
|
| 103 |
+
11. Point out the model badge showing the actual Gemma GGUF model label.
|
| 104 |
+
12. Export CSV.
|
| 105 |
+
|
| 106 |
+
## Lessons
|
| 107 |
+
|
| 108 |
+
To fill after testing:
|
| 109 |
+
|
| 110 |
+
- What the small model handled well:
|
| 111 |
+
- What the human correction loop caught:
|
| 112 |
+
- What changed in the app because of the user:
|
| 113 |
+
- What should not be automated:
|
README.md
CHANGED
|
@@ -1,13 +1,235 @@
|
|
| 1 |
---
|
| 2 |
title: Small Shop Ledger
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
python_version: '3.13'
|
| 9 |
app_file: app.py
|
| 10 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Small Shop Ledger
|
| 3 |
+
emoji: 🧾
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 5.50.0
|
|
|
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: Messy shopkeeper notes in. Clear books by closing time.
|
| 12 |
+
tags:
|
| 13 |
+
- build-small-hackathon
|
| 14 |
+
- gradio
|
| 15 |
+
- llama-cpp
|
| 16 |
+
- local-first
|
| 17 |
+
- finance
|
| 18 |
---
|
| 19 |
|
| 20 |
+
# Small Shop Ledger
|
| 21 |
+
|
| 22 |
+
Messy notes in. Clear books by closing time.
|
| 23 |
+
|
| 24 |
+
A small-model Gradio app for turning messy shopkeeper notes into a clean ledger.
|
| 25 |
+
|
| 26 |
+
The app accepts pasted notes and optional voice notes, extracts expenses/income,
|
| 27 |
+
assigns categories, tracks due items, and drafts follow-up reminders. It is built
|
| 28 |
+
for the Build Small hackathon's Backyard AI trail: a specific, practical helper
|
| 29 |
+
for a real person who keeps money notes in scraps, voice messages, or memory.
|
| 30 |
+
|
| 31 |
+
## Why This Fits The Brief
|
| 32 |
+
|
| 33 |
+
- **Specific problem:** small shop owners and parents often record purchases,
|
| 34 |
+
credit, supplier payments, and customer dues in unstructured notes.
|
| 35 |
+
- **Small-model fit:** extraction, categorization, and reminder drafting fit well
|
| 36 |
+
inside a 12B-class instruction model.
|
| 37 |
+
- **No cloud inference APIs:** the language model runs through `llama.cpp` via
|
| 38 |
+
`llama-cpp-python`.
|
| 39 |
+
- **Gradio canvas:** the whole interface is a Gradio app.
|
| 40 |
+
- **Custom cockpit UI:** the app uses a dark Shop OS layout with persistent
|
| 41 |
+
capture, pulse, and assistant zones instead of default Gradio tabs.
|
| 42 |
+
- **Modal GPU deployment:** `modal_app.py` serves the Gradio UI and runs
|
| 43 |
+
llama.cpp inference on a Modal `A10` GPU worker.
|
| 44 |
+
|
| 45 |
+
## Features
|
| 46 |
+
|
| 47 |
+
- Paste messy notes such as `paid Ravi 1200 for rice bags, remind me Friday`.
|
| 48 |
+
- Record or upload a voice note; optional local Whisper transcription handles it.
|
| 49 |
+
- Upload receipts, bills, note photos, or PDFs; PDFs are rendered to page
|
| 50 |
+
images and Gemma reads the visual document content through llama.cpp.
|
| 51 |
+
- Work inside a custom Shop OS Cockpit: a capture rail, live Shop Pulse center,
|
| 52 |
+
sticky ledger assistant, Action Inbox, People workbench, and Ledger Archive.
|
| 53 |
+
- Extract structured ledger rows with amount, party, item, category, status, and
|
| 54 |
+
confidence.
|
| 55 |
+
- See the actual Gemma/GGUF model label in the app instead of the internal
|
| 56 |
+
mounted filename.
|
| 57 |
+
- See a live dashboard for net cash, cash in, cash out, due amount, follow-ups,
|
| 58 |
+
and average extraction confidence.
|
| 59 |
+
- Let the app pick an insight graph based on the ledger state: unpaid dues,
|
| 60 |
+
expense pressure, cashflow over time, confidence review, or category mix. The
|
| 61 |
+
Plotly charts use the same dark ledger theme as the rest of the app.
|
| 62 |
+
- Generate a Gemma-powered "today's shop pulse" from the structured ledger rows.
|
| 63 |
+
- Ask local ledger questions in a dedicated chat such as "Who owes me most?",
|
| 64 |
+
"What should I follow up today?", and "Where did cash go?"
|
| 65 |
+
- Ask the ledger by voice; local transcription turns a spoken question into a
|
| 66 |
+
grounded ledger answer.
|
| 67 |
+
- Run a Ledger Command Palette for unpaid rows, WhatsApp follow-ups, risk scan,
|
| 68 |
+
cash summary, and QuickBooks-style export planning.
|
| 69 |
+
- Compose charts from plain-language questions with a safe Gemma/local chart
|
| 70 |
+
selector.
|
| 71 |
+
- Review People Memory cards for customers and suppliers, with due status,
|
| 72 |
+
usual category, and suggested next message.
|
| 73 |
+
- Scan the Anomaly Lantern for high-value dues, missing amounts, repeat unpaid
|
| 74 |
+
parties, and low-confidence rows.
|
| 75 |
+
- View a Shop Pulse Timeline that turns rows into a visual story of cash in,
|
| 76 |
+
cash out, dues, and logged events.
|
| 77 |
+
- Close the day with a Daily Closing Ritual checklist before export.
|
| 78 |
+
- Review field intelligence: top category, most active party, biggest entry,
|
| 79 |
+
watch-list risks, and a daily field note.
|
| 80 |
+
- Use the automation queue to turn due items into follow-up actions, reminder
|
| 81 |
+
cadence, and ready-to-send polite, friendly, or firm message scripts.
|
| 82 |
+
- Review low-confidence or incomplete rows in a dedicated Review Desk before
|
| 83 |
+
exporting the CSV.
|
| 84 |
+
- Export the ledger as CSV.
|
| 85 |
+
- Run with a heuristic dev fallback before downloading a large GGUF model.
|
| 86 |
+
|
| 87 |
+
## Project Layout
|
| 88 |
+
|
| 89 |
+
```text
|
| 90 |
+
app.py Local Gradio entrypoint
|
| 91 |
+
modal_app.py Modal deployment entrypoint
|
| 92 |
+
shop_ledger/ App logic, UI, model backends
|
| 93 |
+
tests/ Unit tests for parsing and processing
|
| 94 |
+
ARCHITECTURE.md System architecture and data flow
|
| 95 |
+
UI_DESIGN.md Custom Gradio cockpit layout and UX rules
|
| 96 |
+
ROADMAP.md Future feature ideas and next sprint options
|
| 97 |
+
FIELD_NOTES.md Hackathon report starter
|
| 98 |
+
DEPLOYMENT.md Modal deployment notes
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
## Local Setup
|
| 102 |
+
|
| 103 |
+
```bash
|
| 104 |
+
python3 -m venv .venv
|
| 105 |
+
source .venv/bin/activate
|
| 106 |
+
pip install -r requirements.txt
|
| 107 |
+
python3 app.py
|
| 108 |
+
```
|
| 109 |
+
|
| 110 |
+
By default the app runs in `mock` mode. Set `LEDGER_MODEL_MODE=llama` and point
|
| 111 |
+
`LLAMA_GGUF_PATH` at a local GGUF file to use llama.cpp:
|
| 112 |
+
|
| 113 |
+
```bash
|
| 114 |
+
export LEDGER_MODEL_MODE=llama
|
| 115 |
+
export LLAMA_GGUF_PATH=/path/to/gemma-4-12b-it-UD-Q4_K_XL.gguf
|
| 116 |
+
python3 app.py
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
## Tests
|
| 120 |
+
|
| 121 |
+
```bash
|
| 122 |
+
python3 -m unittest discover -v
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
Install `requirements.txt` first so the Plotly-backed insight tests can import
|
| 126 |
+
the dashboard code.
|
| 127 |
+
|
| 128 |
+
## Hugging Face Space Setup
|
| 129 |
+
|
| 130 |
+
The hackathon submission should point to a Hugging Face Gradio Space. This repo
|
| 131 |
+
can run directly as a Space because `app.py` launches the Gradio Blocks app.
|
| 132 |
+
|
| 133 |
+
For a lightweight Space demo, leave `LEDGER_MODEL_MODE=mock`. For the full
|
| 134 |
+
small-model path on a GPU Space, set:
|
| 135 |
+
|
| 136 |
+
| Variable | Example |
|
| 137 |
+
| --- | --- |
|
| 138 |
+
| `LEDGER_MODEL_MODE` | `llama` |
|
| 139 |
+
| `LLAMA_GGUF_REPO` | `unsloth/gemma-4-12b-it-GGUF` |
|
| 140 |
+
| `LLAMA_GGUF_FILE` | `gemma-4-12b-it-UD-Q4_K_XL.gguf` |
|
| 141 |
+
| `LLAMA_MODEL_LABEL` | `unsloth/gemma-4-12b-it-GGUF / gemma-4-12b-it-UD-Q4_K_XL.gguf / llama.cpp` |
|
| 142 |
+
| `LLAMA_N_GPU_LAYERS` | `-1` |
|
| 143 |
+
| `LLAMA_N_CTX` | `2048` |
|
| 144 |
+
|
| 145 |
+
If `LLAMA_GGUF_PATH` is set, it wins. That keeps the Modal deployment using its
|
| 146 |
+
mounted model volume while allowing a Space to resolve the GGUF from Hugging
|
| 147 |
+
Face Hub.
|
| 148 |
+
|
| 149 |
+
## Modal Deployment
|
| 150 |
+
|
| 151 |
+
See [DEPLOYMENT.md](DEPLOYMENT.md) for the full Modal runbook.
|
| 152 |
+
|
| 153 |
+
Short version:
|
| 154 |
+
|
| 155 |
+
```bash
|
| 156 |
+
pip install modal
|
| 157 |
+
modal setup
|
| 158 |
+
modal run modal_app.py::download_model
|
| 159 |
+
modal deploy modal_app.py
|
| 160 |
+
```
|
| 161 |
+
|
| 162 |
+
The production Modal deployment uses a GPU worker for llama.cpp inference:
|
| 163 |
+
|
| 164 |
+
- GPU: Modal `A10`
|
| 165 |
+
- Runtime: `llama-cpp-python` CUDA wheel
|
| 166 |
+
- Model: `unsloth/gemma-4-12b-it-GGUF`
|
| 167 |
+
- Quant: `gemma-4-12b-it-UD-Q4_K_XL.gguf`
|
| 168 |
+
|
| 169 |
+
Smoke test the GPU model path:
|
| 170 |
+
|
| 171 |
+
```bash
|
| 172 |
+
modal run modal_app.py::smoke
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
Expected signal:
|
| 176 |
+
|
| 177 |
+
```text
|
| 178 |
+
model_used: unsloth/gemma-4-12b-it-GGUF / gemma-4-12b-it-UD-Q4_K_XL.gguf / llama.cpp
|
| 179 |
+
gpu_type: A10
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
The app reads these environment variables:
|
| 183 |
+
|
| 184 |
+
| Variable | Purpose |
|
| 185 |
+
| --- | --- |
|
| 186 |
+
| `LEDGER_MODEL_MODE` | `mock` or `llama` |
|
| 187 |
+
| `LLAMA_GGUF_PATH` | Local path to a GGUF model |
|
| 188 |
+
| `LLAMA_GGUF_REPO` | Optional Hugging Face repo to download a GGUF on startup |
|
| 189 |
+
| `LLAMA_GGUF_FILE` | Optional GGUF filename inside `LLAMA_GGUF_REPO` |
|
| 190 |
+
| `LLAMA_MODEL_LABEL` | Human-readable model label shown in the UI |
|
| 191 |
+
| `LLAMA_N_GPU_LAYERS` | Number of llama.cpp layers to offload, `-1` on Modal |
|
| 192 |
+
| `LLAMA_N_CTX` | llama.cpp context window, `2048` on Modal |
|
| 193 |
+
| `WHISPER_MODEL_SIZE` | Optional faster-whisper model size, defaults to `tiny` |
|
| 194 |
+
|
| 195 |
+
The Gradio dashboard uses Plotly figures, so Modal installs `plotly>=6.0,<7`
|
| 196 |
+
inside the same image as the UI.
|
| 197 |
+
|
| 198 |
+
Document upload stays off-grid too: PDFs are rendered with PyMuPDF, images are
|
| 199 |
+
encoded as local data URLs, and Gemma 4 12B receives them through llama.cpp's
|
| 200 |
+
multimodal `image_url` chat message format.
|
| 201 |
+
|
| 202 |
+
## Model Notes
|
| 203 |
+
|
| 204 |
+
The configured Modal model is
|
| 205 |
+
[`unsloth/gemma-4-12b-it-GGUF`](https://huggingface.co/unsloth/gemma-4-12b-it-GGUF)
|
| 206 |
+
with `gemma-4-12b-it-UD-Q4_K_XL.gguf`. The model card lists Gemma 4 12B
|
| 207 |
+
Unified at 11.95B parameters, which is inside the hackathon's <=32B constraint.
|
| 208 |
+
|
| 209 |
+
The implementation deliberately avoids external LLM APIs so the demo can earn
|
| 210 |
+
the local-first/off-grid spirit of the hackathon. Modal is used for deployment,
|
| 211 |
+
not as a hosted inference API.
|
| 212 |
+
|
| 213 |
+
## Demo Flow
|
| 214 |
+
|
| 215 |
+
1. Add a messy text or voice note.
|
| 216 |
+
2. Upload a receipt/photo/PDF and show it entering the same ledger flow.
|
| 217 |
+
3. Point to the Shop Pulse center: net cash, due amount, dynamic chart, and
|
| 218 |
+
timeline update without leaving the cockpit.
|
| 219 |
+
4. Ask "Who owes me most?" in the sticky ledger assistant rail.
|
| 220 |
+
5. Generate the daily brief and run a command palette action.
|
| 221 |
+
6. Show the Action Inbox with follow-ups, review items, and anomaly signals.
|
| 222 |
+
7. Open People to show counterparty memory.
|
| 223 |
+
8. Open Ledger Archive and export the CSV.
|
| 224 |
+
|
| 225 |
+
## More Docs
|
| 226 |
+
|
| 227 |
+
- [Architecture](ARCHITECTURE.md): code map, data flow, model contract,
|
| 228 |
+
fallback behavior, and testing strategy.
|
| 229 |
+
- [UI Design](UI_DESIGN.md): Shop OS Cockpit layout, workbench model, CSS hooks,
|
| 230 |
+
and demo flow.
|
| 231 |
+
- [Deployment](DEPLOYMENT.md): Modal GPU deployment, model volume, smoke tests,
|
| 232 |
+
logs, and troubleshooting.
|
| 233 |
+
- [Roadmap](ROADMAP.md): dynamic graphs, modern dashboard layout, daily briefs,
|
| 234 |
+
review mode, counterparty cards, and other future ideas.
|
| 235 |
+
- [Field Notes](FIELD_NOTES.md): hackathon report starter and demo beats.
|
ROADMAP.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Roadmap And Feature Ideas
|
| 2 |
+
|
| 3 |
+
This app is strongest when it feels like a tiny night-shift co-pilot for a real
|
| 4 |
+
shopkeeper: practical, tactile, and a little magical. The ideas below keep that
|
| 5 |
+
theme while pushing the demo toward a more modern, world-class experience.
|
| 6 |
+
|
| 7 |
+
## Highest-Impact Next Features
|
| 8 |
+
|
| 9 |
+
### 1. Dynamic Insight Graphs
|
| 10 |
+
|
| 11 |
+
Status: shipped in the first enhancement sprint.
|
| 12 |
+
|
| 13 |
+
Generate charts based on the ledger rows and the current question the app thinks
|
| 14 |
+
is most important. The app now has a deterministic chart planner and Plotly
|
| 15 |
+
graphs for due radar, spend pressure, cashflow, confidence review, category mix,
|
| 16 |
+
and people exposure.
|
| 17 |
+
|
| 18 |
+
Examples:
|
| 19 |
+
|
| 20 |
+
- If dues are high: show a due-by-counterparty bar chart.
|
| 21 |
+
- If expenses dominate: show expense category breakdown.
|
| 22 |
+
- If many rows exist across dates: show cashflow over time.
|
| 23 |
+
- If confidence is low: show a review queue chart.
|
| 24 |
+
|
| 25 |
+
Implemented path:
|
| 26 |
+
|
| 27 |
+
- Added a chart planner in `shop_ledger/insights.py`.
|
| 28 |
+
- Let deterministic rules pick a chart first.
|
| 29 |
+
- Rendered charts with Gradio `Plot` and Plotly.
|
| 30 |
+
|
| 31 |
+
Future path:
|
| 32 |
+
|
| 33 |
+
- Optionally ask the local LLM to choose between safe chart specs.
|
| 34 |
+
- Add user-selectable chart questions such as "who owes me most?" or "what is
|
| 35 |
+
eating cash?"
|
| 36 |
+
|
| 37 |
+
### 2. Ledger Command Center Layout
|
| 38 |
+
|
| 39 |
+
Status: shipped in the Shop OS Cockpit sprint.
|
| 40 |
+
|
| 41 |
+
Rearrange the UI into a more modern operational console:
|
| 42 |
+
|
| 43 |
+
- Left rail: input capture and session status.
|
| 44 |
+
- Center: active dashboard cards and chart.
|
| 45 |
+
- Right rail: follow-up queue and risk alerts.
|
| 46 |
+
- Bottom: raw ledger table.
|
| 47 |
+
|
| 48 |
+
The app now has a three-zone cockpit:
|
| 49 |
+
|
| 50 |
+
- Capture rail for text, audio, documents, currency, examples, and conflict
|
| 51 |
+
handling.
|
| 52 |
+
- Shop Pulse center for KPIs, dynamic charts, timeline, and field intelligence.
|
| 53 |
+
- Ledger Assistant rail for daily brief, Ask My Ledger, voice questions,
|
| 54 |
+
command palette, reminders, and closing ritual.
|
| 55 |
+
|
| 56 |
+
Supporting workflows are grouped into an Action Inbox plus People and Ledger
|
| 57 |
+
Archive workbenches instead of one tab per feature.
|
| 58 |
+
|
| 59 |
+
### 3. LLM-Generated Daily Brief
|
| 60 |
+
|
| 61 |
+
Status: shipped in the Daily Brief enhancement sprint.
|
| 62 |
+
|
| 63 |
+
After every few entries, generate a short "shopkeeper briefing":
|
| 64 |
+
|
| 65 |
+
```text
|
| 66 |
+
Today cash is negative because inventory spend is high. Nimal and Saman need
|
| 67 |
+
follow-up. Tea packets are the biggest open due item.
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
The dashboard now has a "Today's Shop Pulse" panel. It shows a local fallback
|
| 71 |
+
brief immediately and can call Gemma through the Modal llama.cpp worker for a
|
| 72 |
+
short practical summary from structured rows.
|
| 73 |
+
|
| 74 |
+
### 4. Voice Reply Studio
|
| 75 |
+
|
| 76 |
+
Status: shipped in the second enhancement sprint.
|
| 77 |
+
|
| 78 |
+
For every due item, generate three reply styles:
|
| 79 |
+
|
| 80 |
+
- polite
|
| 81 |
+
- firm
|
| 82 |
+
- friendly/local
|
| 83 |
+
|
| 84 |
+
The automation queue now generates polite, friendly, and firm scripts for each
|
| 85 |
+
due item. The user can copy the right tone into WhatsApp or SMS. This is very
|
| 86 |
+
demoable and directly useful.
|
| 87 |
+
|
| 88 |
+
### 5. Mistake Review Mode
|
| 89 |
+
|
| 90 |
+
Status: shipped in the third enhancement sprint.
|
| 91 |
+
|
| 92 |
+
Add a "Review low-confidence rows" panel:
|
| 93 |
+
|
| 94 |
+
- show rows below confidence threshold
|
| 95 |
+
- ask simple correction questions
|
| 96 |
+
- update the row in session state
|
| 97 |
+
|
| 98 |
+
The app now has a Review Desk tab that shows low-confidence or incomplete rows
|
| 99 |
+
and asks simple correction questions. A future version can make those questions
|
| 100 |
+
editable and write corrections back into session state.
|
| 101 |
+
|
| 102 |
+
## Futuristic But Still Practical
|
| 103 |
+
|
| 104 |
+
### 6. Shop Pulse Timeline
|
| 105 |
+
|
| 106 |
+
Status: shipped in the Pulse Timeline enhancement sprint.
|
| 107 |
+
|
| 108 |
+
Convert the ledger into a visual day timeline:
|
| 109 |
+
|
| 110 |
+
```text
|
| 111 |
+
09:20 inventory spend
|
| 112 |
+
10:45 sale logged
|
| 113 |
+
13:10 customer due
|
| 114 |
+
17:30 utility bill
|
| 115 |
+
```
|
| 116 |
+
|
| 117 |
+
The app now includes a Pulse Timeline tab with story cards, a Plotly pulse
|
| 118 |
+
chart, and a structured event table. This makes messy notes feel like a story
|
| 119 |
+
of the day.
|
| 120 |
+
|
| 121 |
+
### 7. Counterparty Memory Cards
|
| 122 |
+
|
| 123 |
+
Status: shipped in the memory-card enhancement sprint.
|
| 124 |
+
|
| 125 |
+
Create small profiles for each person or supplier:
|
| 126 |
+
|
| 127 |
+
- total paid
|
| 128 |
+
- total due
|
| 129 |
+
- last interaction
|
| 130 |
+
- usual category
|
| 131 |
+
- suggested next message
|
| 132 |
+
|
| 133 |
+
This is useful for small shops where trust and memory matter more than formal
|
| 134 |
+
accounting.
|
| 135 |
+
|
| 136 |
+
The app now includes People Memory cards and a counterparty table with trust
|
| 137 |
+
pulse, due amount, usual category, last item, and suggested next message.
|
| 138 |
+
|
| 139 |
+
### 8. What Changed Since Yesterday?
|
| 140 |
+
|
| 141 |
+
If persistence is added, the app can summarize:
|
| 142 |
+
|
| 143 |
+
- new dues
|
| 144 |
+
- cleared dues
|
| 145 |
+
- rising expense categories
|
| 146 |
+
- repeat late payers
|
| 147 |
+
- unusual entries
|
| 148 |
+
|
| 149 |
+
This becomes a daily habit rather than a one-off tool.
|
| 150 |
+
|
| 151 |
+
### 9. "Ask My Ledger"
|
| 152 |
+
|
| 153 |
+
Status: shipped in the Ask My Ledger enhancement sprint.
|
| 154 |
+
|
| 155 |
+
A local natural-language query box:
|
| 156 |
+
|
| 157 |
+
```text
|
| 158 |
+
Who owes me the most?
|
| 159 |
+
What did I spend on inventory?
|
| 160 |
+
What should I follow up today?
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
The dashboard now includes a question box. Common questions are answered
|
| 164 |
+
deterministically from structured rows, and Modal production can route the same
|
| 165 |
+
rows to Gemma for a concise local/off-grid answer.
|
| 166 |
+
|
| 167 |
+
### 10. Tiny Forecasts
|
| 168 |
+
|
| 169 |
+
Use simple local forecasting, not a big ML system:
|
| 170 |
+
|
| 171 |
+
- expected cash-in if dues are paid
|
| 172 |
+
- likely week-end cash position
|
| 173 |
+
- category spend trend
|
| 174 |
+
|
| 175 |
+
This adds value without leaving the small-model spirit.
|
| 176 |
+
|
| 177 |
+
## Additional Shipped Ideas
|
| 178 |
+
|
| 179 |
+
### Ledger Command Palette
|
| 180 |
+
|
| 181 |
+
Status: shipped.
|
| 182 |
+
|
| 183 |
+
The dashboard now has a command palette for unpaid rows, WhatsApp follow-ups,
|
| 184 |
+
risk scans, cash summaries, and QuickBooks-style export planning.
|
| 185 |
+
|
| 186 |
+
### AI Chart Composer
|
| 187 |
+
|
| 188 |
+
Status: shipped.
|
| 189 |
+
|
| 190 |
+
The chart wall now accepts plain-language chart questions. Modal production can
|
| 191 |
+
ask Gemma to choose from safe chart specs; local mode uses deterministic rules.
|
| 192 |
+
|
| 193 |
+
### Anomaly Lantern
|
| 194 |
+
|
| 195 |
+
Status: shipped.
|
| 196 |
+
|
| 197 |
+
The app now flags high-value dues, missing amounts, low-confidence rows,
|
| 198 |
+
unusually large entries, and repeat unpaid parties.
|
| 199 |
+
|
| 200 |
+
### Shopkeeper Voice Mode
|
| 201 |
+
|
| 202 |
+
Status: shipped.
|
| 203 |
+
|
| 204 |
+
Ask My Ledger now supports spoken questions using the same local transcription
|
| 205 |
+
path as voice notes.
|
| 206 |
+
|
| 207 |
+
### Daily Closing Ritual
|
| 208 |
+
|
| 209 |
+
Status: shipped.
|
| 210 |
+
|
| 211 |
+
The app now includes an end-of-day checklist and closing summary for cash,
|
| 212 |
+
dues, review items, anomalies, and export readiness.
|
| 213 |
+
|
| 214 |
+
## Hackathon Merit Badge Opportunities
|
| 215 |
+
|
| 216 |
+
### Off The Grid
|
| 217 |
+
|
| 218 |
+
Already aligned:
|
| 219 |
+
|
| 220 |
+
- no cloud LLM API
|
| 221 |
+
- llama.cpp runtime
|
| 222 |
+
- GGUF model
|
| 223 |
+
|
| 224 |
+
### Llama Champion
|
| 225 |
+
|
| 226 |
+
Already aligned:
|
| 227 |
+
|
| 228 |
+
- `llama-cpp-python`
|
| 229 |
+
- GGUF model
|
| 230 |
+
- Modal GPU worker
|
| 231 |
+
|
| 232 |
+
### Off-Brand
|
| 233 |
+
|
| 234 |
+
Aligned:
|
| 235 |
+
|
| 236 |
+
- custom dark UI
|
| 237 |
+
- Shop OS Cockpit layout
|
| 238 |
+
- persistent assistant rail
|
| 239 |
+
- action inbox
|
| 240 |
+
- themed Plotly graph wall
|
| 241 |
+
- People and Ledger Archive workbenches
|
| 242 |
+
|
| 243 |
+
Next step:
|
| 244 |
+
|
| 245 |
+
- richer CSS
|
| 246 |
+
- possible custom Gradio frontend layer if the installed Gradio version exposes
|
| 247 |
+
the needed APIs in the future
|
| 248 |
+
|
| 249 |
+
### Field Notes
|
| 250 |
+
|
| 251 |
+
Already started:
|
| 252 |
+
|
| 253 |
+
- `FIELD_NOTES.md`
|
| 254 |
+
|
| 255 |
+
Next step:
|
| 256 |
+
|
| 257 |
+
- add real user interview notes
|
| 258 |
+
- add before/after screenshots
|
| 259 |
+
- add demo observations
|
| 260 |
+
|
| 261 |
+
## Recommended Next Sprint
|
| 262 |
+
|
| 263 |
+
Build these in order:
|
| 264 |
+
|
| 265 |
+
1. Dynamic charts in the Dashboard tab.
|
| 266 |
+
2. Right-side automation queue preview beside the note input.
|
| 267 |
+
3. LLM-generated daily brief.
|
| 268 |
+
4. Review mode for low-confidence rows.
|
| 269 |
+
5. Counterparty memory cards.
|
| 270 |
+
|
| 271 |
+
That gives the app an impressive demo arc:
|
| 272 |
+
|
| 273 |
+
```text
|
| 274 |
+
messy note -> clean ledger -> live dashboard -> smart chart -> follow-up script
|
| 275 |
+
-> daily brief -> CSV export
|
| 276 |
+
```
|
UI_DESIGN.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# UI Design And Layout System
|
| 2 |
+
|
| 3 |
+
Small Shop Ledger is organized as a small-shop operating cockpit, not a
|
| 4 |
+
generic Gradio demo. The layout is designed for a shopkeeper who wants to move
|
| 5 |
+
from messy capture to concrete action without hunting through many equal tabs.
|
| 6 |
+
|
| 7 |
+
Product tagline: Messy notes in. Clear books by closing time.
|
| 8 |
+
|
| 9 |
+
## Product Shape
|
| 10 |
+
|
| 11 |
+
The screen is split into four operating zones:
|
| 12 |
+
|
| 13 |
+
```text
|
| 14 |
+
Status strip
|
| 15 |
+
Model status, row count, and session health
|
| 16 |
+
|
| 17 |
+
Shop OS Cockpit
|
| 18 |
+
Capture rail | Shop Pulse center | Ledger Assistant rail
|
| 19 |
+
|
| 20 |
+
Action Inbox
|
| 21 |
+
Follow-ups, review items, and anomaly signals
|
| 22 |
+
|
| 23 |
+
Workbenches
|
| 24 |
+
People memory and ledger archive
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
This keeps the most common loop visible:
|
| 28 |
+
|
| 29 |
+
1. Capture a note, voice clip, or document.
|
| 30 |
+
2. Watch the ledger pulse update.
|
| 31 |
+
3. Ask the assistant what matters.
|
| 32 |
+
4. Clear actions before exporting.
|
| 33 |
+
|
| 34 |
+
## Cockpit Layout
|
| 35 |
+
|
| 36 |
+
### Capture Rail
|
| 37 |
+
|
| 38 |
+
The left rail owns all intake:
|
| 39 |
+
|
| 40 |
+
- written note
|
| 41 |
+
- voice note
|
| 42 |
+
- document upload
|
| 43 |
+
- input conflict selector
|
| 44 |
+
- currency
|
| 45 |
+
- add and clear controls
|
| 46 |
+
- example notes
|
| 47 |
+
|
| 48 |
+
The rail stays sticky on desktop because the user should always be able to add
|
| 49 |
+
the next note without scrolling back to the top. On mobile it becomes a normal
|
| 50 |
+
stack.
|
| 51 |
+
|
| 52 |
+
### Shop Pulse Center
|
| 53 |
+
|
| 54 |
+
The center column is the primary attention area:
|
| 55 |
+
|
| 56 |
+
- live KPI dashboard
|
| 57 |
+
- chart composer
|
| 58 |
+
- chart director
|
| 59 |
+
- main Plotly graph
|
| 60 |
+
- supporting signal graphs
|
| 61 |
+
- shop pulse timeline
|
| 62 |
+
- field intelligence
|
| 63 |
+
|
| 64 |
+
This is where the app turns ledger rows into a story. The chart composer lets
|
| 65 |
+
Gemma or the deterministic fallback pick a safe chart spec from a plain-language
|
| 66 |
+
question, while the timeline makes the day feel visible.
|
| 67 |
+
|
| 68 |
+
### Ledger Assistant Rail
|
| 69 |
+
|
| 70 |
+
The right rail is a persistent co-pilot:
|
| 71 |
+
|
| 72 |
+
- running totals and reminders
|
| 73 |
+
- LLM Daily Brief
|
| 74 |
+
- full Ask My Ledger chat
|
| 75 |
+
- voice question mode
|
| 76 |
+
- prompt suggestions
|
| 77 |
+
- command palette
|
| 78 |
+
- daily closing ritual
|
| 79 |
+
|
| 80 |
+
The assistant rail is sticky on desktop because questions and actions should be
|
| 81 |
+
available while the user scans charts, reminders, or the archive.
|
| 82 |
+
|
| 83 |
+
## Action Inbox
|
| 84 |
+
|
| 85 |
+
The Action Inbox merges three previously separate areas:
|
| 86 |
+
|
| 87 |
+
- follow-up automation
|
| 88 |
+
- review desk
|
| 89 |
+
- anomaly lantern
|
| 90 |
+
|
| 91 |
+
The user-facing cards appear first. The heavier operational tables are tucked
|
| 92 |
+
inside an accordion for demos and deeper inspection. This avoids making the app
|
| 93 |
+
look like a spreadsheet while preserving the export/review detail.
|
| 94 |
+
|
| 95 |
+
## Workbenches
|
| 96 |
+
|
| 97 |
+
The remaining tabs are intentionally few:
|
| 98 |
+
|
| 99 |
+
| Workbench | Purpose |
|
| 100 |
+
| --- | --- |
|
| 101 |
+
| People | Counterparty memory, trust pulse, and party totals. |
|
| 102 |
+
| Ledger Archive | Raw ledger rows, CSV export, categories, closing checklist, and event table. |
|
| 103 |
+
|
| 104 |
+
This replaces the older one-tab-per-feature structure. Features now live where
|
| 105 |
+
the user expects them rather than competing as top-level destinations.
|
| 106 |
+
|
| 107 |
+
## Styling Rules
|
| 108 |
+
|
| 109 |
+
- Dark theme is the default.
|
| 110 |
+
- Cards use an 8px radius and thin ledger-colored borders.
|
| 111 |
+
- The app avoids a single-hue palette: green means money/healthy, gold means
|
| 112 |
+
attention, red means risk, and blue means insight.
|
| 113 |
+
- Plotly figures inherit the same background, grid, and hover label treatment.
|
| 114 |
+
- Raw data tables are secondary surfaces, not the first thing the user sees.
|
| 115 |
+
- Desktop uses a three-zone cockpit; mobile collapses into one column.
|
| 116 |
+
|
| 117 |
+
## Demo Flow
|
| 118 |
+
|
| 119 |
+
For the hackathon video, the recommended flow is:
|
| 120 |
+
|
| 121 |
+
1. Add a messy text note.
|
| 122 |
+
2. Upload a bill or receipt.
|
| 123 |
+
3. Point to the Shop Pulse center: KPIs, graph, and timeline update.
|
| 124 |
+
4. Ask "Who owes me most?" in the assistant rail.
|
| 125 |
+
5. Generate the daily brief.
|
| 126 |
+
6. Show the Action Inbox with follow-up/review/anomaly cards.
|
| 127 |
+
7. Open People to show memory cards.
|
| 128 |
+
8. Open Ledger Archive and export CSV.
|
| 129 |
+
|
| 130 |
+
## Implementation Map
|
| 131 |
+
|
| 132 |
+
The layout lives in `shop_ledger/ui.py`.
|
| 133 |
+
|
| 134 |
+
Important CSS hooks:
|
| 135 |
+
|
| 136 |
+
- `#cockpit-shell`
|
| 137 |
+
- `#input-dock`
|
| 138 |
+
- `#pulse-core`
|
| 139 |
+
- `#assistant-rail`
|
| 140 |
+
- `#action-inbox`
|
| 141 |
+
- `#action-grid`
|
| 142 |
+
- `#workbench-tabs`
|
| 143 |
+
- `#people-workbench`
|
| 144 |
+
- `#ledger-archive`
|
| 145 |
+
|
| 146 |
+
The insight content still comes from `shop_ledger/insights.py`, which keeps
|
| 147 |
+
layout and business logic separated.
|
app.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
from shop_ledger.ui import build_demo
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
if __name__ == "__main__":
|
| 7 |
+
demo = build_demo()
|
| 8 |
+
port = int(os.getenv("PORT") or os.getenv("GRADIO_SERVER_PORT") or "8051")
|
| 9 |
+
demo.launch(server_port=port)
|
modal_app.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import modal
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
APP_NAME = "voice-notes-shop-ledger"
|
| 9 |
+
MODEL_DIR = "/models"
|
| 10 |
+
DEFAULT_MODEL_FILE = "model.gguf"
|
| 11 |
+
DEFAULT_GGUF_REPO = "unsloth/gemma-4-12b-it-GGUF"
|
| 12 |
+
DEFAULT_GGUF_FILE = "gemma-4-12b-it-UD-Q4_K_XL.gguf"
|
| 13 |
+
MODEL_LABEL = f"{DEFAULT_GGUF_REPO} / {DEFAULT_GGUF_FILE} / llama.cpp"
|
| 14 |
+
GPU_TYPE = "A10"
|
| 15 |
+
|
| 16 |
+
app = modal.App(APP_NAME)
|
| 17 |
+
volume = modal.Volume.from_name("voice-notes-shop-ledger-models", create_if_missing=True)
|
| 18 |
+
|
| 19 |
+
image = (
|
| 20 |
+
modal.Image.from_registry("nvidia/cuda:12.8.1-devel-ubuntu24.04", add_python="3.12")
|
| 21 |
+
.entrypoint([])
|
| 22 |
+
.apt_install("build-essential", "cmake", "git", "libsndfile1", "ninja-build")
|
| 23 |
+
.pip_install(
|
| 24 |
+
"fastapi[standard]>=0.115,<0.116",
|
| 25 |
+
"gradio>=5.5,<6",
|
| 26 |
+
"huggingface-hub>=0.36,<1",
|
| 27 |
+
"pandas>=2.2,<3",
|
| 28 |
+
"plotly>=6.0,<7",
|
| 29 |
+
"PyMuPDF>=1.24,<2",
|
| 30 |
+
"Pillow>=10,<12",
|
| 31 |
+
"pydantic>=2.9,<3",
|
| 32 |
+
"faster-whisper>=1.1,<2",
|
| 33 |
+
)
|
| 34 |
+
.run_commands(
|
| 35 |
+
"pip install --no-cache-dir --force-reinstall --prefer-binary --only-binary=:all: "
|
| 36 |
+
"'llama-cpp-python>=0.3.16,<0.4' "
|
| 37 |
+
"--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu125"
|
| 38 |
+
)
|
| 39 |
+
.env(
|
| 40 |
+
{
|
| 41 |
+
"PYTHONPATH": "/root",
|
| 42 |
+
"LEDGER_MODEL_MODE": "llama",
|
| 43 |
+
"LLAMA_GGUF_PATH": f"{MODEL_DIR}/{DEFAULT_MODEL_FILE}",
|
| 44 |
+
"LLAMA_MODEL_LABEL": MODEL_LABEL,
|
| 45 |
+
"LLAMA_N_GPU_LAYERS": "-1",
|
| 46 |
+
"LLAMA_N_CTX": "2048",
|
| 47 |
+
"WHISPER_MODEL_SIZE": "tiny",
|
| 48 |
+
}
|
| 49 |
+
)
|
| 50 |
+
.add_local_dir("shop_ledger", remote_path="/root/shop_ledger")
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@app.function(image=image, volumes={MODEL_DIR: volume}, timeout=3600)
|
| 55 |
+
def download_model(repo_id: str = DEFAULT_GGUF_REPO, filename: str = DEFAULT_GGUF_FILE) -> str:
|
| 56 |
+
"""Download a GGUF model from Hugging Face into a persistent Modal volume."""
|
| 57 |
+
from huggingface_hub import hf_hub_download
|
| 58 |
+
|
| 59 |
+
target_path = Path(MODEL_DIR) / DEFAULT_MODEL_FILE
|
| 60 |
+
downloaded_path = hf_hub_download(
|
| 61 |
+
repo_id=repo_id,
|
| 62 |
+
filename=filename,
|
| 63 |
+
local_dir=MODEL_DIR,
|
| 64 |
+
local_dir_use_symlinks=False,
|
| 65 |
+
)
|
| 66 |
+
source = Path(downloaded_path)
|
| 67 |
+
if source != target_path:
|
| 68 |
+
target_path.write_bytes(source.read_bytes())
|
| 69 |
+
volume.commit()
|
| 70 |
+
return f"Downloaded {repo_id}/{filename} to {target_path}"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@app.cls(
|
| 74 |
+
image=image,
|
| 75 |
+
volumes={MODEL_DIR: volume},
|
| 76 |
+
gpu=GPU_TYPE,
|
| 77 |
+
cpu=8,
|
| 78 |
+
memory=32768,
|
| 79 |
+
timeout=1800,
|
| 80 |
+
)
|
| 81 |
+
class LedgerAgent:
|
| 82 |
+
@modal.enter()
|
| 83 |
+
def load(self) -> None:
|
| 84 |
+
from shop_ledger.processor import LedgerProcessor
|
| 85 |
+
|
| 86 |
+
volume.reload()
|
| 87 |
+
self.processor = LedgerProcessor.from_env()
|
| 88 |
+
|
| 89 |
+
@modal.method()
|
| 90 |
+
def process(self, note: str, currency: str = "LKR", image_urls: list[str] | None = None) -> dict:
|
| 91 |
+
result = self.processor.process(note, currency=currency, image_urls=image_urls)
|
| 92 |
+
return result.model_dump(mode="json")
|
| 93 |
+
|
| 94 |
+
@modal.method()
|
| 95 |
+
def daily_brief(self, rows: list[dict], currency: str = "LKR") -> dict:
|
| 96 |
+
return self.processor.daily_brief(rows, currency=currency)
|
| 97 |
+
|
| 98 |
+
@modal.method()
|
| 99 |
+
def ask_ledger(self, rows: list[dict], question: str, currency: str = "LKR") -> dict:
|
| 100 |
+
return self.processor.ask_ledger(rows, question, currency=currency)
|
| 101 |
+
|
| 102 |
+
@modal.method()
|
| 103 |
+
def choose_chart(self, rows: list[dict], question: str) -> dict:
|
| 104 |
+
return self.processor.choose_chart(rows, question)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@app.function(image=image, volumes={MODEL_DIR: volume}, gpu=GPU_TYPE, cpu=8, memory=32768, timeout=1800)
|
| 108 |
+
def smoke_test_model() -> dict:
|
| 109 |
+
"""Run a sample ledger extraction inside Modal and return model metadata."""
|
| 110 |
+
from shop_ledger.processor import LedgerProcessor
|
| 111 |
+
|
| 112 |
+
volume.reload()
|
| 113 |
+
processor = LedgerProcessor.from_env()
|
| 114 |
+
result = processor.process(
|
| 115 |
+
"paid Ravi 1200 for rice bags, customer Nimal owes 750 for tea packets",
|
| 116 |
+
currency="LKR",
|
| 117 |
+
)
|
| 118 |
+
return {
|
| 119 |
+
"model_used": result.model_used,
|
| 120 |
+
"entry_count": len(result.entries),
|
| 121 |
+
"amounts": [entry.amount for entry in result.entries],
|
| 122 |
+
"statuses": [entry.payment_status for entry in result.entries],
|
| 123 |
+
"questions": result.questions,
|
| 124 |
+
"gpu_type": GPU_TYPE,
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.function(image=image, max_containers=1, timeout=600)
|
| 129 |
+
@modal.concurrent(max_inputs=50)
|
| 130 |
+
@modal.asgi_app()
|
| 131 |
+
def fastapi_app():
|
| 132 |
+
from fastapi import FastAPI
|
| 133 |
+
from gradio.routes import mount_gradio_app
|
| 134 |
+
from shop_ledger.ui import build_demo
|
| 135 |
+
|
| 136 |
+
web_app = FastAPI(title="Small Shop Ledger")
|
| 137 |
+
|
| 138 |
+
def process_remote(note: str, currency: str, image_urls: list[str] | None = None) -> dict:
|
| 139 |
+
return LedgerAgent().process.remote(note, currency, image_urls)
|
| 140 |
+
|
| 141 |
+
def daily_brief_remote(rows: list[dict], currency: str) -> dict:
|
| 142 |
+
return LedgerAgent().daily_brief.remote(rows, currency)
|
| 143 |
+
|
| 144 |
+
def ask_ledger_remote(rows: list[dict], question: str, currency: str) -> dict:
|
| 145 |
+
return LedgerAgent().ask_ledger.remote(rows, question, currency)
|
| 146 |
+
|
| 147 |
+
def choose_chart_remote(rows: list[dict], question: str) -> dict:
|
| 148 |
+
return LedgerAgent().choose_chart.remote(rows, question)
|
| 149 |
+
|
| 150 |
+
demo = build_demo(
|
| 151 |
+
process_fn=process_remote,
|
| 152 |
+
daily_brief_fn=daily_brief_remote,
|
| 153 |
+
ask_ledger_fn=ask_ledger_remote,
|
| 154 |
+
chart_composer_fn=choose_chart_remote,
|
| 155 |
+
)
|
| 156 |
+
return mount_gradio_app(app=web_app, blocks=demo, path="/")
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@app.local_entrypoint()
|
| 160 |
+
def main(repo_id: str = DEFAULT_GGUF_REPO, filename: str = DEFAULT_GGUF_FILE) -> None:
|
| 161 |
+
print(download_model.remote(repo_id, filename))
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@app.local_entrypoint()
|
| 165 |
+
def smoke() -> None:
|
| 166 |
+
print(smoke_test_model.remote())
|
requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi[standard]>=0.115,<0.116
|
| 2 |
+
gradio>=5.5,<6
|
| 3 |
+
huggingface-hub>=0.36,<1
|
| 4 |
+
llama-cpp-python>=0.3.16,<0.4
|
| 5 |
+
pandas>=2.2,<3
|
| 6 |
+
plotly>=6.0,<7
|
| 7 |
+
PyMuPDF>=1.24,<2
|
| 8 |
+
Pillow>=10,<12
|
| 9 |
+
pydantic>=2.9,<3
|
| 10 |
+
faster-whisper>=1.1,<2
|
shop_ledger/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Small Shop Ledger."""
|
| 2 |
+
|
| 3 |
+
__all__ = ["__version__"]
|
| 4 |
+
|
| 5 |
+
__version__ = "0.1.0"
|
shop_ledger/heuristics.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from datetime import date
|
| 5 |
+
|
| 6 |
+
from shop_ledger.schema import Direction, LedgerEntry, LedgerResult, PaymentStatus
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
AMOUNT_RE = re.compile(
|
| 10 |
+
r"(?:(?:rs\.?|lkr|රු)\s*)?(\d[\d,]*(?:\.\d{1,2})?)", re.IGNORECASE
|
| 11 |
+
)
|
| 12 |
+
SPLIT_RE = re.compile(
|
| 13 |
+
r"\s*(?:[.;\n]|,\s*(?=(?:paid|bought|sold|got|received|customer|supplier|owes|owe|due)\b))\s*",
|
| 14 |
+
re.IGNORECASE,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
CATEGORY_KEYWORDS = {
|
| 18 |
+
"inventory": ("rice", "tea", "milk", "flour", "sugar", "packet", "bags", "stock", "goods"),
|
| 19 |
+
"utilities": ("electric", "water", "wifi", "internet", "phone", "bill"),
|
| 20 |
+
"rent": ("rent", "lease"),
|
| 21 |
+
"wages": ("salary", "wage", "helper", "staff", "worker"),
|
| 22 |
+
"transport": ("bus", "fuel", "petrol", "diesel", "delivery", "transport", "tuk"),
|
| 23 |
+
"maintenance": ("repair", "fix", "paint", "clean", "replace"),
|
| 24 |
+
"sales": ("sold", "sale", "customer", "received", "got"),
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
DUE_WORDS = ("owes", "owe", "due", "credit", "later", "unpaid", "balance")
|
| 28 |
+
PAID_WORDS = ("paid", "bought", "spent", "gave", "settled")
|
| 29 |
+
INCOME_WORDS = ("sold", "received", "got", "collected", "customer paid")
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def heuristic_extract(note: str, currency: str = "LKR") -> LedgerResult:
|
| 33 |
+
cleaned = " ".join(note.strip().split())
|
| 34 |
+
if not cleaned:
|
| 35 |
+
return LedgerResult(cleaned_note="", questions=["Add a note first."])
|
| 36 |
+
|
| 37 |
+
parts = [part.strip(" ,") for part in SPLIT_RE.split(note) if part.strip(" ,")]
|
| 38 |
+
entries: list[LedgerEntry] = []
|
| 39 |
+
reminders: list[str] = []
|
| 40 |
+
|
| 41 |
+
for part in parts:
|
| 42 |
+
amount_match = AMOUNT_RE.search(part)
|
| 43 |
+
amount = float(amount_match.group(1).replace(",", "")) if amount_match else 0.0
|
| 44 |
+
lowered = part.lower()
|
| 45 |
+
direction = infer_direction(lowered)
|
| 46 |
+
status = infer_status(lowered, direction)
|
| 47 |
+
counterparty = infer_counterparty(part)
|
| 48 |
+
item = infer_item(part)
|
| 49 |
+
category = infer_category(lowered, direction)
|
| 50 |
+
reminder = infer_reminder(part, counterparty, amount, currency, status)
|
| 51 |
+
|
| 52 |
+
if reminder:
|
| 53 |
+
reminders.append(reminder)
|
| 54 |
+
|
| 55 |
+
entries.append(
|
| 56 |
+
LedgerEntry(
|
| 57 |
+
date=date.today().isoformat(),
|
| 58 |
+
direction=direction,
|
| 59 |
+
counterparty=counterparty,
|
| 60 |
+
item=item,
|
| 61 |
+
amount=amount,
|
| 62 |
+
currency=currency,
|
| 63 |
+
category=category,
|
| 64 |
+
payment_status=status,
|
| 65 |
+
reminder=reminder,
|
| 66 |
+
confidence=0.58 if amount else 0.34,
|
| 67 |
+
original_note=part,
|
| 68 |
+
)
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
questions = []
|
| 72 |
+
if any(entry.amount == 0 for entry in entries):
|
| 73 |
+
questions.append("Some rows have no amount. Ask the user to confirm the missing value.")
|
| 74 |
+
if any(not entry.counterparty for entry in entries):
|
| 75 |
+
questions.append("Some rows have no person or business name.")
|
| 76 |
+
|
| 77 |
+
return LedgerResult(
|
| 78 |
+
entries=entries,
|
| 79 |
+
reminders=reminders,
|
| 80 |
+
questions=questions,
|
| 81 |
+
cleaned_note=cleaned,
|
| 82 |
+
model_used="heuristic",
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def infer_direction(text: str) -> Direction:
|
| 87 |
+
if any(word in text for word in INCOME_WORDS):
|
| 88 |
+
return Direction.income
|
| 89 |
+
if any(word in text for word in PAID_WORDS):
|
| 90 |
+
return Direction.expense
|
| 91 |
+
if any(word in text for word in DUE_WORDS):
|
| 92 |
+
return Direction.income
|
| 93 |
+
return Direction.unknown
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def infer_status(text: str, direction: Direction) -> PaymentStatus:
|
| 97 |
+
if any(word in text for word in DUE_WORDS):
|
| 98 |
+
return PaymentStatus.due
|
| 99 |
+
if direction in (Direction.expense, Direction.income):
|
| 100 |
+
return PaymentStatus.paid
|
| 101 |
+
return PaymentStatus.unknown
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def infer_category(text: str, direction: Direction) -> str:
|
| 105 |
+
for category, keywords in CATEGORY_KEYWORDS.items():
|
| 106 |
+
if any(keyword in text for keyword in keywords):
|
| 107 |
+
return category
|
| 108 |
+
if direction == Direction.income:
|
| 109 |
+
return "sales"
|
| 110 |
+
if direction == Direction.expense:
|
| 111 |
+
return "general expense"
|
| 112 |
+
return "uncategorized"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def infer_counterparty(text: str) -> str:
|
| 116 |
+
patterns = [
|
| 117 |
+
r"\b(?:paid|gave|from|to|customer|supplier)\s+([A-Z][A-Za-z.'-]+)",
|
| 118 |
+
r"\b([A-Z][A-Za-z.'-]+)\s+(?:owes|paid|gave|bought|sold)",
|
| 119 |
+
]
|
| 120 |
+
for pattern in patterns:
|
| 121 |
+
match = re.search(pattern, text)
|
| 122 |
+
if match:
|
| 123 |
+
return match.group(1).strip()
|
| 124 |
+
return ""
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def infer_item(text: str) -> str:
|
| 128 |
+
without_amount = AMOUNT_RE.sub("", text)
|
| 129 |
+
match = re.search(r"\bfor\s+(.+)$", without_amount, re.IGNORECASE)
|
| 130 |
+
if match:
|
| 131 |
+
return cleanup_item(match.group(1))
|
| 132 |
+
for phrase in ("bought", "sold", "paid", "received"):
|
| 133 |
+
match = re.search(rf"\b{phrase}\b\s+(.+)$", without_amount, re.IGNORECASE)
|
| 134 |
+
if match:
|
| 135 |
+
return cleanup_item(match.group(1))
|
| 136 |
+
return cleanup_item(without_amount)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def cleanup_item(text: str) -> str:
|
| 140 |
+
cleaned = re.sub(r"\b(?:rs\.?|lkr|paid|gave|from|to|customer|supplier)\b", "", text, flags=re.IGNORECASE)
|
| 141 |
+
cleaned = re.sub(r"\s+", " ", cleaned).strip(" ,-")
|
| 142 |
+
return cleaned[:80]
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def infer_reminder(
|
| 146 |
+
text: str,
|
| 147 |
+
counterparty: str,
|
| 148 |
+
amount: float,
|
| 149 |
+
currency: str,
|
| 150 |
+
status: PaymentStatus,
|
| 151 |
+
) -> str:
|
| 152 |
+
if status != PaymentStatus.due:
|
| 153 |
+
if "remind" not in text.lower():
|
| 154 |
+
return ""
|
| 155 |
+
|
| 156 |
+
who = counterparty or "the customer"
|
| 157 |
+
amount_text = f"{currency} {amount:,.0f}" if amount else "the amount"
|
| 158 |
+
return f"Follow up with {who} about {amount_text}."
|
shop_ledger/insights.py
ADDED
|
@@ -0,0 +1,1177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import defaultdict
|
| 4 |
+
from datetime import date, datetime
|
| 5 |
+
from html import escape
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import plotly.graph_objects as go
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
PALETTE = {
|
| 12 |
+
"bg": "#080c12",
|
| 13 |
+
"plot": "#0b1017",
|
| 14 |
+
"panel": "#10151d",
|
| 15 |
+
"grid": "rgba(157, 177, 154, 0.16)",
|
| 16 |
+
"axis": "rgba(243, 244, 236, 0.58)",
|
| 17 |
+
"ink": "#f3f4ec",
|
| 18 |
+
"muted": "#a8b3a5",
|
| 19 |
+
"green": "#8bdc8b",
|
| 20 |
+
"gold": "#e6b450",
|
| 21 |
+
"red": "#ff7a68",
|
| 22 |
+
"blue": "#8ab4ff",
|
| 23 |
+
"violet": "#b8a6ff",
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def money(value: float, currency: str = "LKR") -> str:
|
| 28 |
+
return f"{currency} {value:,.0f}"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def h(value: Any) -> str:
|
| 32 |
+
return escape(str(value or ""), quote=True)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def amount(row: dict[str, Any]) -> float:
|
| 36 |
+
try:
|
| 37 |
+
return float(row.get("amount") or 0)
|
| 38 |
+
except (TypeError, ValueError):
|
| 39 |
+
return 0.0
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def primary_currency(rows: list[dict[str, Any]]) -> str:
|
| 43 |
+
for row in rows:
|
| 44 |
+
currency = row.get("currency")
|
| 45 |
+
if currency:
|
| 46 |
+
return str(currency)
|
| 47 |
+
return "LKR"
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def compute_metrics(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
| 51 |
+
currency = primary_currency(rows)
|
| 52 |
+
paid_expense = sum(amount(row) for row in rows if row.get("direction") == "expense" and row.get("payment_status") == "paid")
|
| 53 |
+
paid_income = sum(amount(row) for row in rows if row.get("direction") == "income" and row.get("payment_status") == "paid")
|
| 54 |
+
due_income = sum(amount(row) for row in rows if row.get("direction") == "income" and row.get("payment_status") == "due")
|
| 55 |
+
due_expense = sum(amount(row) for row in rows if row.get("direction") == "expense" and row.get("payment_status") == "due")
|
| 56 |
+
reminders = [row for row in rows if row.get("reminder") or row.get("payment_status") == "due"]
|
| 57 |
+
confidence_values = [float(row.get("confidence") or 0) for row in rows]
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
"currency": currency,
|
| 61 |
+
"row_count": len(rows),
|
| 62 |
+
"paid_expense": paid_expense,
|
| 63 |
+
"paid_income": paid_income,
|
| 64 |
+
"net_cash": paid_income - paid_expense,
|
| 65 |
+
"due_income": due_income,
|
| 66 |
+
"due_expense": due_expense,
|
| 67 |
+
"open_followups": len(reminders),
|
| 68 |
+
"avg_confidence": sum(confidence_values) / len(confidence_values) if confidence_values else 0,
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def parse_row_date(value: Any) -> date:
|
| 73 |
+
if isinstance(value, datetime):
|
| 74 |
+
return value.date()
|
| 75 |
+
if isinstance(value, date):
|
| 76 |
+
return value
|
| 77 |
+
text = str(value or "").strip()
|
| 78 |
+
if not text:
|
| 79 |
+
return date.today()
|
| 80 |
+
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%b %d", "%B %d"):
|
| 81 |
+
try:
|
| 82 |
+
parsed = datetime.strptime(text, fmt)
|
| 83 |
+
if fmt in ("%b %d", "%B %d"):
|
| 84 |
+
return parsed.replace(year=date.today().year).date()
|
| 85 |
+
return parsed.date()
|
| 86 |
+
except ValueError:
|
| 87 |
+
continue
|
| 88 |
+
try:
|
| 89 |
+
return datetime.fromisoformat(text).date()
|
| 90 |
+
except ValueError:
|
| 91 |
+
return date.today()
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def top_counter(rows: list[dict[str, Any]], key: str) -> tuple[str, float]:
|
| 95 |
+
totals: dict[str, float] = defaultdict(float)
|
| 96 |
+
for row in rows:
|
| 97 |
+
value = str(row.get(key) or "").strip() or "uncategorized"
|
| 98 |
+
totals[value] += amount(row)
|
| 99 |
+
if not totals:
|
| 100 |
+
return "none", 0.0
|
| 101 |
+
return max(totals.items(), key=lambda item: item[1])
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def biggest_transaction(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
|
| 105 |
+
if not rows:
|
| 106 |
+
return None
|
| 107 |
+
return max(rows, key=amount)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def followup_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 111 |
+
queue = []
|
| 112 |
+
for index, row in enumerate(rows, start=1):
|
| 113 |
+
if row.get("payment_status") != "due" and not row.get("reminder"):
|
| 114 |
+
continue
|
| 115 |
+
who = row.get("counterparty") or "Unknown"
|
| 116 |
+
value = amount(row)
|
| 117 |
+
currency = row.get("currency") or primary_currency(rows)
|
| 118 |
+
item = row.get("item") or "ledger item"
|
| 119 |
+
reminder = row.get("reminder") or f"Follow up with {who} about {money(value, currency)}."
|
| 120 |
+
variants = reply_variants(who, value, currency, item)
|
| 121 |
+
queue.append(
|
| 122 |
+
{
|
| 123 |
+
"priority": "High" if value >= 5000 else "Normal",
|
| 124 |
+
"counterparty": who,
|
| 125 |
+
"amount": money(value, currency),
|
| 126 |
+
"item": item,
|
| 127 |
+
"next_action": reminder,
|
| 128 |
+
"cadence": "Today, then every 2 days" if value >= 5000 else "Tomorrow",
|
| 129 |
+
"script": variants["polite"],
|
| 130 |
+
"polite_script": variants["polite"],
|
| 131 |
+
"friendly_script": variants["friendly"],
|
| 132 |
+
"firm_script": variants["firm"],
|
| 133 |
+
"source_row": index,
|
| 134 |
+
}
|
| 135 |
+
)
|
| 136 |
+
return sorted(queue, key=lambda row: (row["priority"] != "High", row["counterparty"]))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def reply_variants(who: str, value: float, currency: str, item: str) -> dict[str, str]:
|
| 140 |
+
amount_text = money(value, currency)
|
| 141 |
+
return {
|
| 142 |
+
"polite": f"Hi {who}, just checking on {amount_text} for {item}. Can you confirm when it will be settled?",
|
| 143 |
+
"friendly": f"Hi {who}, quick reminder from the shop ledger: {amount_text} is still open for {item}. Tell me what works for you.",
|
| 144 |
+
"firm": f"Hi {who}, {amount_text} for {item} is still pending. Please settle it today or send a clear payment time.",
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def category_breakdown(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 149 |
+
totals: dict[str, float] = defaultdict(float)
|
| 150 |
+
currency = primary_currency(rows)
|
| 151 |
+
for row in rows:
|
| 152 |
+
totals[str(row.get("category") or "uncategorized")] += amount(row)
|
| 153 |
+
return [
|
| 154 |
+
{"category": category, "total": total, "display": money(total, currency)}
|
| 155 |
+
for category, total in sorted(totals.items(), key=lambda item: item[1], reverse=True)
|
| 156 |
+
]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def party_breakdown(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 160 |
+
totals: dict[str, float] = defaultdict(float)
|
| 161 |
+
due: dict[str, float] = defaultdict(float)
|
| 162 |
+
currency = primary_currency(rows)
|
| 163 |
+
for row in rows:
|
| 164 |
+
party = str(row.get("counterparty") or "Unknown")
|
| 165 |
+
totals[party] += amount(row)
|
| 166 |
+
if row.get("payment_status") == "due":
|
| 167 |
+
due[party] += amount(row)
|
| 168 |
+
return [
|
| 169 |
+
{"party": party, "total": money(total, currency), "due": money(due[party], currency)}
|
| 170 |
+
for party, total in sorted(totals.items(), key=lambda item: item[1], reverse=True)
|
| 171 |
+
]
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def counterparty_memory_cards(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 175 |
+
currency = primary_currency(rows)
|
| 176 |
+
profiles: dict[str, dict[str, Any]] = {}
|
| 177 |
+
for index, row in enumerate(rows, start=1):
|
| 178 |
+
party = str(row.get("counterparty") or "Unknown").strip() or "Unknown"
|
| 179 |
+
profile = profiles.setdefault(
|
| 180 |
+
party,
|
| 181 |
+
{
|
| 182 |
+
"party": party,
|
| 183 |
+
"total_moved": 0.0,
|
| 184 |
+
"paid": 0.0,
|
| 185 |
+
"due": 0.0,
|
| 186 |
+
"row_count": 0,
|
| 187 |
+
"last_row": index,
|
| 188 |
+
"last_item": "",
|
| 189 |
+
"categories": defaultdict(int),
|
| 190 |
+
"items": defaultdict(int),
|
| 191 |
+
},
|
| 192 |
+
)
|
| 193 |
+
value = amount(row)
|
| 194 |
+
profile["total_moved"] += value
|
| 195 |
+
profile["row_count"] += 1
|
| 196 |
+
profile["last_row"] = index
|
| 197 |
+
profile["last_item"] = row.get("item") or "ledger item"
|
| 198 |
+
profile["categories"][str(row.get("category") or "uncategorized")] += 1
|
| 199 |
+
profile["items"][str(row.get("item") or "ledger item")] += 1
|
| 200 |
+
if row.get("payment_status") == "due":
|
| 201 |
+
profile["due"] += value
|
| 202 |
+
elif row.get("payment_status") == "paid":
|
| 203 |
+
profile["paid"] += value
|
| 204 |
+
|
| 205 |
+
cards = []
|
| 206 |
+
for profile in profiles.values():
|
| 207 |
+
category = max(profile["categories"].items(), key=lambda item: item[1])[0] if profile["categories"] else "uncategorized"
|
| 208 |
+
usual_item = max(profile["items"].items(), key=lambda item: item[1])[0] if profile["items"] else "ledger item"
|
| 209 |
+
trust = "Clear" if profile["due"] == 0 else "Watch" if profile["due"] < 5000 else "Collect first"
|
| 210 |
+
next_message = (
|
| 211 |
+
f"Thank {profile['party']} and keep trading."
|
| 212 |
+
if profile["due"] == 0
|
| 213 |
+
else f"Follow up with {profile['party']} about {money(profile['due'], currency)} before the next sale."
|
| 214 |
+
)
|
| 215 |
+
cards.append(
|
| 216 |
+
{
|
| 217 |
+
"party": profile["party"],
|
| 218 |
+
"trust_pulse": trust,
|
| 219 |
+
"total_moved": money(profile["total_moved"], currency),
|
| 220 |
+
"paid": money(profile["paid"], currency),
|
| 221 |
+
"due": money(profile["due"], currency),
|
| 222 |
+
"usual_category": category,
|
| 223 |
+
"usual_item": usual_item,
|
| 224 |
+
"last_item": profile["last_item"],
|
| 225 |
+
"row_count": profile["row_count"],
|
| 226 |
+
"next_message": next_message,
|
| 227 |
+
}
|
| 228 |
+
)
|
| 229 |
+
return sorted(cards, key=lambda card: (card["trust_pulse"] != "Collect first", card["trust_pulse"] != "Watch", card["party"]))
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def build_counterparty_memory_markdown(rows: list[dict[str, Any]]) -> str:
|
| 233 |
+
cards = counterparty_memory_cards(rows)
|
| 234 |
+
if not cards:
|
| 235 |
+
return "### Counterparty Memory\nPeople and supplier memory cards appear after the first ledger entry."
|
| 236 |
+
|
| 237 |
+
blocks = ["### Counterparty Memory", "<div class='memory-grid'>"]
|
| 238 |
+
for card in cards[:9]:
|
| 239 |
+
tone = "risk" if card["trust_pulse"] == "Collect first" else "watch" if card["trust_pulse"] == "Watch" else "clear"
|
| 240 |
+
blocks.append(
|
| 241 |
+
f"<div class='memory-card {tone}'>"
|
| 242 |
+
f"<strong>{h(card['party'])}</strong>"
|
| 243 |
+
f"<span>{h(card['trust_pulse'])}</span>"
|
| 244 |
+
f"<p>{h(card['total_moved'])} moved · {h(card['due'])} due</p>"
|
| 245 |
+
f"<small>Usually: {h(card['usual_category'])} / {h(card['usual_item'])}</small>"
|
| 246 |
+
f"<code>{h(card['next_message'])}</code>"
|
| 247 |
+
"</div>"
|
| 248 |
+
)
|
| 249 |
+
blocks.append("</div>")
|
| 250 |
+
return "\n".join(blocks)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def review_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 254 |
+
review = []
|
| 255 |
+
for index, row in enumerate(rows, start=1):
|
| 256 |
+
confidence = float(row.get("confidence") or 0)
|
| 257 |
+
missing = [
|
| 258 |
+
label
|
| 259 |
+
for label, value in {
|
| 260 |
+
"counterparty": row.get("counterparty"),
|
| 261 |
+
"item": row.get("item"),
|
| 262 |
+
"amount": row.get("amount"),
|
| 263 |
+
"payment status": row.get("payment_status"),
|
| 264 |
+
}.items()
|
| 265 |
+
if value in (None, "", 0)
|
| 266 |
+
]
|
| 267 |
+
if confidence >= 0.6 and not missing:
|
| 268 |
+
continue
|
| 269 |
+
amount_text = money(amount(row), row.get("currency") or primary_currency(rows))
|
| 270 |
+
issue = "Low confidence" if confidence < 0.6 else "Missing detail"
|
| 271 |
+
if missing:
|
| 272 |
+
issue += f": {', '.join(missing)}"
|
| 273 |
+
review.append(
|
| 274 |
+
{
|
| 275 |
+
"source_row": index,
|
| 276 |
+
"issue": issue,
|
| 277 |
+
"confidence": f"{confidence:.0%}",
|
| 278 |
+
"counterparty": row.get("counterparty") or "Unknown",
|
| 279 |
+
"item": row.get("item") or "Unknown item",
|
| 280 |
+
"amount": amount_text,
|
| 281 |
+
"question": review_question(row, missing),
|
| 282 |
+
}
|
| 283 |
+
)
|
| 284 |
+
return review
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
def review_question(row: dict[str, Any], missing: list[str]) -> str:
|
| 288 |
+
who = row.get("counterparty") or "this person"
|
| 289 |
+
item = row.get("item") or "this item"
|
| 290 |
+
if missing:
|
| 291 |
+
return f"Can you confirm the {', '.join(missing)} for {item}?"
|
| 292 |
+
return f"Can you confirm {who}, {item}, and the amount before exporting?"
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
def build_dashboard_markdown(rows: list[dict[str, Any]]) -> str:
|
| 296 |
+
metrics = compute_metrics(rows)
|
| 297 |
+
currency = metrics["currency"]
|
| 298 |
+
if not rows:
|
| 299 |
+
return (
|
| 300 |
+
"### Command Center\n"
|
| 301 |
+
"No entries yet. Add one note to light up the cash desk, chart board, and follow-up radar."
|
| 302 |
+
)
|
| 303 |
+
|
| 304 |
+
return (
|
| 305 |
+
"### Command Center\n"
|
| 306 |
+
f"<div class='metric-grid'>"
|
| 307 |
+
f"<div class='metric-card'><span>Net cash</span><strong>{money(metrics['net_cash'], currency)}</strong></div>"
|
| 308 |
+
f"<div class='metric-card'><span>Cash in</span><strong>{money(metrics['paid_income'], currency)}</strong></div>"
|
| 309 |
+
f"<div class='metric-card'><span>Cash out</span><strong>{money(metrics['paid_expense'], currency)}</strong></div>"
|
| 310 |
+
f"<div class='metric-card'><span>Still due</span><strong>{money(metrics['due_income'], currency)}</strong></div>"
|
| 311 |
+
f"<div class='metric-card'><span>Follow-ups</span><strong>{metrics['open_followups']}</strong></div>"
|
| 312 |
+
f"<div class='metric-card'><span>Avg confidence</span><strong>{metrics['avg_confidence']:.0%}</strong></div>"
|
| 313 |
+
f"</div>"
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def build_chart_plan(rows: list[dict[str, Any]]) -> dict[str, str]:
|
| 318 |
+
if not rows:
|
| 319 |
+
return {
|
| 320 |
+
"chart": "empty",
|
| 321 |
+
"title": "Waiting for ledger signal",
|
| 322 |
+
"question": "What will the first note reveal?",
|
| 323 |
+
"reason": "The graph board wakes up after the first extracted row.",
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
metrics = compute_metrics(rows)
|
| 327 |
+
low_confidence = sum(1 for row in rows if float(row.get("confidence") or 0) < 0.6)
|
| 328 |
+
dated_rows = {parse_row_date(row.get("date")) for row in rows}
|
| 329 |
+
due_parties = {str(row.get("counterparty") or "Unknown") for row in rows if row.get("payment_status") == "due"}
|
| 330 |
+
|
| 331 |
+
if metrics["due_income"] > 0 and len(due_parties) >= 1:
|
| 332 |
+
return {
|
| 333 |
+
"chart": "due_by_party",
|
| 334 |
+
"title": "Who needs a follow-up first?",
|
| 335 |
+
"question": "Where is unpaid money concentrated?",
|
| 336 |
+
"reason": f"{money(metrics['due_income'], metrics['currency'])} is still due across {len(due_parties)} contact(s).",
|
| 337 |
+
}
|
| 338 |
+
if metrics["paid_expense"] > metrics["paid_income"] and metrics["paid_expense"] > 0:
|
| 339 |
+
return {
|
| 340 |
+
"chart": "expense_categories",
|
| 341 |
+
"title": "What is pulling cash out?",
|
| 342 |
+
"question": "Which spend category is dominating today?",
|
| 343 |
+
"reason": "Paid expenses are currently higher than collected income.",
|
| 344 |
+
}
|
| 345 |
+
if len(dated_rows) > 1:
|
| 346 |
+
return {
|
| 347 |
+
"chart": "cashflow",
|
| 348 |
+
"title": "How is cash moving over time?",
|
| 349 |
+
"question": "Which days changed the cash position?",
|
| 350 |
+
"reason": f"The ledger spans {len(dated_rows)} dates, so a timeline is now useful.",
|
| 351 |
+
}
|
| 352 |
+
if low_confidence > 0:
|
| 353 |
+
return {
|
| 354 |
+
"chart": "confidence_review",
|
| 355 |
+
"title": "Which rows need human eyes?",
|
| 356 |
+
"question": "Where might the extractor be uncertain?",
|
| 357 |
+
"reason": f"{low_confidence} row(s) are below 60% confidence.",
|
| 358 |
+
}
|
| 359 |
+
return {
|
| 360 |
+
"chart": "category_mix",
|
| 361 |
+
"title": "What shape is today's trade?",
|
| 362 |
+
"question": "Which categories are carrying the ledger?",
|
| 363 |
+
"reason": "No urgent risk dominates, so the board shows category mix.",
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
CHART_SPECS = {
|
| 368 |
+
"due_by_party": "Due radar",
|
| 369 |
+
"expense_categories": "Spend pressure",
|
| 370 |
+
"cashflow": "Cashflow trail",
|
| 371 |
+
"confidence_review": "Review queue",
|
| 372 |
+
"category_mix": "Category mix",
|
| 373 |
+
"party_exposure": "People ledger",
|
| 374 |
+
"timeline": "Shop pulse timeline",
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
|
| 378 |
+
def chart_spec_from_question(rows: list[dict[str, Any]], question: str) -> dict[str, str]:
|
| 379 |
+
text = question.strip().lower()
|
| 380 |
+
if not rows:
|
| 381 |
+
return {"chart": "empty", "reason": "Add ledger rows before composing charts.", "model_used": "local rules"}
|
| 382 |
+
if any(word in text for word in ("owe", "due", "unpaid", "collect")):
|
| 383 |
+
chart = "due_by_party"
|
| 384 |
+
reason = "The question is about unpaid money and collections."
|
| 385 |
+
elif any(word in text for word in ("spend", "spent", "expense", "cash go", "cash out")):
|
| 386 |
+
chart = "expense_categories"
|
| 387 |
+
reason = "The question is about where cash went."
|
| 388 |
+
elif any(word in text for word in ("time", "trend", "flow", "day", "cash low")):
|
| 389 |
+
chart = "cashflow"
|
| 390 |
+
reason = "The question is about movement over time or cash position."
|
| 391 |
+
elif any(word in text for word in ("confidence", "wrong", "review", "mistake", "uncertain")):
|
| 392 |
+
chart = "confidence_review"
|
| 393 |
+
reason = "The question is about extraction quality."
|
| 394 |
+
elif any(word in text for word in ("person", "people", "supplier", "customer", "party")):
|
| 395 |
+
chart = "party_exposure"
|
| 396 |
+
reason = "The question is about people and suppliers."
|
| 397 |
+
elif any(word in text for word in ("story", "timeline", "happened")):
|
| 398 |
+
chart = "timeline"
|
| 399 |
+
reason = "The question asks for the story of the day."
|
| 400 |
+
else:
|
| 401 |
+
plan = build_chart_plan(rows)
|
| 402 |
+
chart = plan["chart"]
|
| 403 |
+
reason = plan["reason"]
|
| 404 |
+
return {"chart": chart, "reason": reason, "model_used": "local rules"}
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def figure_for_chart_id(rows: list[dict[str, Any]], chart: str) -> go.Figure:
|
| 408 |
+
builders = {
|
| 409 |
+
"due_by_party": due_by_party_figure,
|
| 410 |
+
"expense_categories": expense_category_figure,
|
| 411 |
+
"cashflow": cashflow_figure,
|
| 412 |
+
"confidence_review": confidence_review_figure,
|
| 413 |
+
"category_mix": category_mix_figure,
|
| 414 |
+
"party_exposure": party_exposure_figure,
|
| 415 |
+
"timeline": timeline_figure,
|
| 416 |
+
}
|
| 417 |
+
return builders.get(chart, category_mix_figure)(rows) if rows else empty_figure()
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def build_chart_composer_markdown(question: str, spec: dict[str, str]) -> str:
|
| 421 |
+
chart = spec.get("chart", "category_mix")
|
| 422 |
+
title = CHART_SPECS.get(chart, "Category mix")
|
| 423 |
+
reason = spec.get("reason") or "The ledger shape makes this view useful."
|
| 424 |
+
model_used = spec.get("model_used", "local rules")
|
| 425 |
+
prompt = question.strip() or "Auto-compose from the ledger."
|
| 426 |
+
return (
|
| 427 |
+
"### AI Chart Composer\n"
|
| 428 |
+
f"**Question:** {prompt}\n\n"
|
| 429 |
+
f"**Chart:** {title}\n\n"
|
| 430 |
+
f"**Why:** {reason}\n\n"
|
| 431 |
+
f"<small>Composer: {model_used}</small>"
|
| 432 |
+
)
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def build_chart_markdown(rows: list[dict[str, Any]]) -> str:
|
| 436 |
+
plan = build_chart_plan(rows)
|
| 437 |
+
if not rows:
|
| 438 |
+
return (
|
| 439 |
+
"### Chart Director\n"
|
| 440 |
+
f"**Question:** {plan['question']}\n\n"
|
| 441 |
+
f"{plan['reason']}"
|
| 442 |
+
)
|
| 443 |
+
return (
|
| 444 |
+
"### Chart Director\n"
|
| 445 |
+
f"**Question:** {plan['question']}\n\n"
|
| 446 |
+
f"**Graph chosen:** {plan['title']}\n\n"
|
| 447 |
+
f"**Why now:** {plan['reason']}"
|
| 448 |
+
)
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def build_insights_markdown(rows: list[dict[str, Any]]) -> str:
|
| 452 |
+
if not rows:
|
| 453 |
+
return "### Field Intelligence\nInsights appear after the first ledger entry."
|
| 454 |
+
|
| 455 |
+
metrics = compute_metrics(rows)
|
| 456 |
+
currency = metrics["currency"]
|
| 457 |
+
top_category, top_category_total = top_counter(rows, "category")
|
| 458 |
+
top_party, top_party_total = top_counter(rows, "counterparty")
|
| 459 |
+
biggest = biggest_transaction(rows)
|
| 460 |
+
risks = risk_flags(rows)
|
| 461 |
+
risk_text = "\n".join(f"- {risk}" for risk in risks) if risks else "- No urgent risks detected."
|
| 462 |
+
biggest_text = "none"
|
| 463 |
+
if biggest:
|
| 464 |
+
biggest_text = f"{money(amount(biggest), currency)} for {biggest.get('item') or 'unknown item'}"
|
| 465 |
+
|
| 466 |
+
return (
|
| 467 |
+
"### Field Intelligence\n"
|
| 468 |
+
f"- Top category: **{top_category}** ({money(top_category_total, currency)})\n"
|
| 469 |
+
f"- Most active party: **{top_party}** ({money(top_party_total, currency)})\n"
|
| 470 |
+
f"- Biggest entry: **{biggest_text}**\n"
|
| 471 |
+
f"- Open follow-up value: **{money(metrics['due_income'], currency)}**\n\n"
|
| 472 |
+
"### Watch List\n"
|
| 473 |
+
f"{risk_text}\n\n"
|
| 474 |
+
"### Field Note\n"
|
| 475 |
+
f"{daily_field_note(rows)}"
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
def build_daily_brief_markdown(rows: list[dict[str, Any]], brief: str | None = None, model_used: str = "local rules") -> str:
|
| 480 |
+
if not rows:
|
| 481 |
+
return "### Today's Shop Pulse\nAdd a few entries, then ask Gemma for the day's pulse."
|
| 482 |
+
text = brief or daily_brief_fallback(rows)
|
| 483 |
+
return f"### Today's Shop Pulse\n{text}\n\n<small>Brief: {model_used}</small>"
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def daily_brief_fallback(rows: list[dict[str, Any]]) -> str:
|
| 487 |
+
metrics = compute_metrics(rows)
|
| 488 |
+
currency = metrics["currency"]
|
| 489 |
+
top_category, top_category_total = top_counter(rows, "category")
|
| 490 |
+
queue = followup_rows(rows)
|
| 491 |
+
if queue:
|
| 492 |
+
lead_followup = f"{queue[0]['counterparty']} needs the first follow-up for {queue[0]['amount']}."
|
| 493 |
+
else:
|
| 494 |
+
lead_followup = "No urgent follow-up is waiting."
|
| 495 |
+
return (
|
| 496 |
+
f"{len(rows)} row(s) logged today. Net cash is {money(metrics['net_cash'], currency)}. "
|
| 497 |
+
f"Money moved most through {top_category} ({money(top_category_total, currency)}). "
|
| 498 |
+
f"{lead_followup}"
|
| 499 |
+
)
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
def answer_ledger_question(rows: list[dict[str, Any]], question: str) -> str:
|
| 503 |
+
text = question.strip().lower()
|
| 504 |
+
if not rows:
|
| 505 |
+
return "No ledger rows yet. Add a note, voice memo, or document first."
|
| 506 |
+
if not text:
|
| 507 |
+
return "Ask something like: who owes me most, what should I follow up today, or where did cash go?"
|
| 508 |
+
|
| 509 |
+
currency = primary_currency(rows)
|
| 510 |
+
if any(phrase in text for phrase in ("owe", "owes", "due", "who owes")):
|
| 511 |
+
due_by_party: dict[str, float] = defaultdict(float)
|
| 512 |
+
for row in rows:
|
| 513 |
+
if row.get("payment_status") == "due":
|
| 514 |
+
due_by_party[str(row.get("counterparty") or "Unknown")] += amount(row)
|
| 515 |
+
if not due_by_party:
|
| 516 |
+
return "No due items are open right now."
|
| 517 |
+
party, total = max(due_by_party.items(), key=lambda item: item[1])
|
| 518 |
+
return f"{party} owes the most: {money(total, currency)}."
|
| 519 |
+
|
| 520 |
+
if any(phrase in text for phrase in ("follow up", "followup", "remind", "today")):
|
| 521 |
+
queue = followup_rows(rows)
|
| 522 |
+
if not queue:
|
| 523 |
+
return "No follow-ups are waiting right now."
|
| 524 |
+
first = queue[0]
|
| 525 |
+
return f"Follow up with {first['counterparty']} first about {first['amount']} for {first['item']}. Suggested cadence: {first['cadence']}."
|
| 526 |
+
|
| 527 |
+
if any(phrase in text for phrase in ("cash go", "spent", "expense", "cash out", "where did cash")):
|
| 528 |
+
expenses = [row for row in rows if row.get("direction") == "expense"]
|
| 529 |
+
if not expenses:
|
| 530 |
+
return "No paid expenses are logged yet."
|
| 531 |
+
top_category, total = top_counter(expenses, "category")
|
| 532 |
+
return f"Cash went mostly to {top_category}: {money(total, currency)}."
|
| 533 |
+
|
| 534 |
+
metrics = compute_metrics(rows)
|
| 535 |
+
return (
|
| 536 |
+
f"Ledger snapshot: {len(rows)} row(s), net cash {money(metrics['net_cash'], currency)}, "
|
| 537 |
+
f"{money(metrics['due_income'], currency)} due, and {metrics['open_followups']} follow-up(s) open."
|
| 538 |
+
)
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
COMMAND_ACTIONS = [
|
| 542 |
+
"Show unpaid",
|
| 543 |
+
"Draft WhatsApp follow-ups",
|
| 544 |
+
"Find risky rows",
|
| 545 |
+
"Summarize cash",
|
| 546 |
+
"Prepare QuickBooks export",
|
| 547 |
+
]
|
| 548 |
+
|
| 549 |
+
|
| 550 |
+
def run_ledger_command(rows: list[dict[str, Any]], command: str) -> str:
|
| 551 |
+
if not rows:
|
| 552 |
+
return "### Command Palette\nAdd ledger rows first, then run a command."
|
| 553 |
+
|
| 554 |
+
action = (command or "").strip() or COMMAND_ACTIONS[0]
|
| 555 |
+
currency = primary_currency(rows)
|
| 556 |
+
if action == "Show unpaid":
|
| 557 |
+
due_rows = [row for row in rows if row.get("payment_status") == "due"]
|
| 558 |
+
if not due_rows:
|
| 559 |
+
return "### Unpaid\nNo unpaid rows are open."
|
| 560 |
+
lines = [
|
| 561 |
+
f"- **{row.get('counterparty') or 'Unknown'}** owes {money(amount(row), currency)} for {row.get('item') or 'ledger item'}."
|
| 562 |
+
for row in due_rows[:8]
|
| 563 |
+
]
|
| 564 |
+
return "### Unpaid\n" + "\n".join(lines)
|
| 565 |
+
|
| 566 |
+
if action == "Draft WhatsApp follow-ups":
|
| 567 |
+
queue = followup_rows(rows)
|
| 568 |
+
if not queue:
|
| 569 |
+
return "### WhatsApp Follow-ups\nNo follow-up messages are waiting."
|
| 570 |
+
lines = [f"- **{item['counterparty']}**: {item['friendly_script']}" for item in queue[:6]]
|
| 571 |
+
return "### WhatsApp Follow-ups\n" + "\n".join(lines)
|
| 572 |
+
|
| 573 |
+
if action == "Find risky rows":
|
| 574 |
+
risks = risk_flags(rows)
|
| 575 |
+
reviews = review_rows(rows)
|
| 576 |
+
lines = [f"- {risk}" for risk in risks]
|
| 577 |
+
lines.extend(f"- Row {item['source_row']}: {item['issue']}." for item in reviews[:5])
|
| 578 |
+
return "### Risk Scan\n" + ("\n".join(lines) if lines else "No obvious risks found.")
|
| 579 |
+
|
| 580 |
+
if action == "Prepare QuickBooks export":
|
| 581 |
+
lines = [
|
| 582 |
+
"- Map `income` rows to Sales Receipt or Invoice import.",
|
| 583 |
+
"- Map `expense` rows to Expense import.",
|
| 584 |
+
"- Use `counterparty` as Customer/Vendor.",
|
| 585 |
+
"- Use `category` as Account/Category.",
|
| 586 |
+
"- Use `item`, `amount`, `date`, and `payment_status` as transaction details.",
|
| 587 |
+
f"- Rows ready for review: **{len(rows)}**.",
|
| 588 |
+
]
|
| 589 |
+
return "### QuickBooks Export Plan\n" + "\n".join(lines)
|
| 590 |
+
|
| 591 |
+
metrics = compute_metrics(rows)
|
| 592 |
+
return (
|
| 593 |
+
"### Cash Summary\n"
|
| 594 |
+
f"- Net cash: **{money(metrics['net_cash'], currency)}**\n"
|
| 595 |
+
f"- Cash in: **{money(metrics['paid_income'], currency)}**\n"
|
| 596 |
+
f"- Cash out: **{money(metrics['paid_expense'], currency)}**\n"
|
| 597 |
+
f"- Still due: **{money(metrics['due_income'], currency)}**"
|
| 598 |
+
)
|
| 599 |
+
|
| 600 |
+
|
| 601 |
+
def anomaly_lantern_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 602 |
+
if not rows:
|
| 603 |
+
return []
|
| 604 |
+
|
| 605 |
+
currency = primary_currency(rows)
|
| 606 |
+
values = [amount(row) for row in rows if amount(row) > 0]
|
| 607 |
+
avg_value = sum(values) / len(values) if values else 0
|
| 608 |
+
due_counts: dict[str, int] = defaultdict(int)
|
| 609 |
+
anomalies = []
|
| 610 |
+
for index, row in enumerate(rows, start=1):
|
| 611 |
+
party = row.get("counterparty") or "Unknown"
|
| 612 |
+
value = amount(row)
|
| 613 |
+
if row.get("payment_status") == "due":
|
| 614 |
+
due_counts[str(party)] += 1
|
| 615 |
+
if value == 0:
|
| 616 |
+
anomalies.append(make_anomaly(index, "Missing amount", "Check", party, row, "No amount was captured."))
|
| 617 |
+
if float(row.get("confidence") or 0) < 0.6:
|
| 618 |
+
anomalies.append(make_anomaly(index, "Low confidence", "Review", party, row, "Extraction confidence is below 60%."))
|
| 619 |
+
if avg_value and value > avg_value * 2.5 and value >= 5000:
|
| 620 |
+
anomalies.append(
|
| 621 |
+
make_anomaly(
|
| 622 |
+
index,
|
| 623 |
+
"Unusually large amount",
|
| 624 |
+
"Watch",
|
| 625 |
+
party,
|
| 626 |
+
row,
|
| 627 |
+
f"{money(value, currency)} is much larger than the current average entry.",
|
| 628 |
+
)
|
| 629 |
+
)
|
| 630 |
+
if row.get("payment_status") == "due" and value >= 5000:
|
| 631 |
+
anomalies.append(
|
| 632 |
+
make_anomaly(index, "High-value due", "Collect", party, row, f"{party} owes {money(value, currency)}.")
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
for party, count in due_counts.items():
|
| 636 |
+
if count > 1:
|
| 637 |
+
anomalies.append(
|
| 638 |
+
{
|
| 639 |
+
"source_row": "",
|
| 640 |
+
"severity": "Watch",
|
| 641 |
+
"signal": "Repeat unpaid party",
|
| 642 |
+
"counterparty": party,
|
| 643 |
+
"item": "multiple due rows",
|
| 644 |
+
"amount": "",
|
| 645 |
+
"reason": f"{party} appears in {count} unpaid rows.",
|
| 646 |
+
}
|
| 647 |
+
)
|
| 648 |
+
severity_order = {"Collect": 0, "Watch": 1, "Review": 2, "Check": 3}
|
| 649 |
+
return sorted(anomalies, key=lambda item: (severity_order.get(item["severity"], 9), str(item["counterparty"])))[:12]
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
def make_anomaly(
|
| 653 |
+
index: int,
|
| 654 |
+
signal: str,
|
| 655 |
+
severity: str,
|
| 656 |
+
party: Any,
|
| 657 |
+
row: dict[str, Any],
|
| 658 |
+
reason: str,
|
| 659 |
+
) -> dict[str, Any]:
|
| 660 |
+
return {
|
| 661 |
+
"source_row": index,
|
| 662 |
+
"severity": severity,
|
| 663 |
+
"signal": signal,
|
| 664 |
+
"counterparty": party or "Unknown",
|
| 665 |
+
"item": row.get("item") or "ledger item",
|
| 666 |
+
"amount": money(amount(row), row.get("currency") or primary_currency([row])),
|
| 667 |
+
"reason": reason,
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
def build_anomaly_lantern_markdown(rows: list[dict[str, Any]]) -> str:
|
| 672 |
+
anomalies = anomaly_lantern_rows(rows)
|
| 673 |
+
if not rows:
|
| 674 |
+
return "### Anomaly Lantern\nSignals appear after ledger rows are added."
|
| 675 |
+
if not anomalies:
|
| 676 |
+
return "### Anomaly Lantern\nNo bright warnings found. Still review before export."
|
| 677 |
+
|
| 678 |
+
blocks = ["### Anomaly Lantern", "<div class='lantern-grid'>"]
|
| 679 |
+
for item in anomalies[:8]:
|
| 680 |
+
tone = item["severity"].lower()
|
| 681 |
+
blocks.append(
|
| 682 |
+
f"<div class='lantern-card {tone}'>"
|
| 683 |
+
f"<strong>{h(item['severity'])} · {h(item['signal'])}</strong>"
|
| 684 |
+
f"<p>{h(item['counterparty'])} · {h(item['item'])} · {h(item['amount'])}</p>"
|
| 685 |
+
f"<code>{h(item['reason'])}</code>"
|
| 686 |
+
"</div>"
|
| 687 |
+
)
|
| 688 |
+
blocks.append("</div>")
|
| 689 |
+
return "\n".join(blocks)
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
def closing_checklist(rows: list[dict[str, Any]]) -> list[dict[str, str]]:
|
| 693 |
+
metrics = compute_metrics(rows)
|
| 694 |
+
followups = followup_rows(rows)
|
| 695 |
+
reviews = review_rows(rows)
|
| 696 |
+
anomalies = anomaly_lantern_rows(rows)
|
| 697 |
+
return [
|
| 698 |
+
{
|
| 699 |
+
"step": "Count cash",
|
| 700 |
+
"status": "Ready" if rows else "Waiting",
|
| 701 |
+
"detail": f"Net cash is {money(metrics['net_cash'], metrics['currency'])}.",
|
| 702 |
+
},
|
| 703 |
+
{
|
| 704 |
+
"step": "Collect dues",
|
| 705 |
+
"status": "Action" if followups else "Clear",
|
| 706 |
+
"detail": f"{len(followups)} follow-up(s) waiting.",
|
| 707 |
+
},
|
| 708 |
+
{
|
| 709 |
+
"step": "Review uncertain rows",
|
| 710 |
+
"status": "Action" if reviews else "Clear",
|
| 711 |
+
"detail": f"{len(reviews)} row(s) need a human check.",
|
| 712 |
+
},
|
| 713 |
+
{
|
| 714 |
+
"step": "Check anomalies",
|
| 715 |
+
"status": "Action" if anomalies else "Clear",
|
| 716 |
+
"detail": f"{len(anomalies)} signal(s) found.",
|
| 717 |
+
},
|
| 718 |
+
{
|
| 719 |
+
"step": "Export ledger",
|
| 720 |
+
"status": "Ready" if rows and not reviews else "Review first",
|
| 721 |
+
"detail": "Download CSV after review for accountant or QuickBooks handoff.",
|
| 722 |
+
},
|
| 723 |
+
]
|
| 724 |
+
|
| 725 |
+
|
| 726 |
+
def build_closing_ritual_markdown(rows: list[dict[str, Any]]) -> str:
|
| 727 |
+
if not rows:
|
| 728 |
+
return "### Daily Closing Ritual\nAdd today’s notes, then close the shop with a guided checklist."
|
| 729 |
+
|
| 730 |
+
metrics = compute_metrics(rows)
|
| 731 |
+
currency = metrics["currency"]
|
| 732 |
+
top_category, top_total = top_counter(rows, "category")
|
| 733 |
+
followups = followup_rows(rows)
|
| 734 |
+
next_followup = (
|
| 735 |
+
f"First follow-up: {followups[0]['counterparty']} for {followups[0]['amount']}."
|
| 736 |
+
if followups
|
| 737 |
+
else "No follow-ups waiting."
|
| 738 |
+
)
|
| 739 |
+
checklist = closing_checklist(rows)
|
| 740 |
+
items = "\n".join(f"- **{item['step']}** · {item['status']}: {item['detail']}" for item in checklist)
|
| 741 |
+
return (
|
| 742 |
+
"### Daily Closing Ritual\n"
|
| 743 |
+
f"Today closes with **{money(metrics['net_cash'], currency)}** net cash, "
|
| 744 |
+
f"**{money(metrics['due_income'], currency)}** still due, and **{len(rows)}** ledger row(s). "
|
| 745 |
+
f"Most movement was in **{top_category}** ({money(top_total, currency)}). {next_followup}\n\n"
|
| 746 |
+
"### Closing Checklist\n"
|
| 747 |
+
f"{items}"
|
| 748 |
+
)
|
| 749 |
+
|
| 750 |
+
|
| 751 |
+
def risk_flags(rows: list[dict[str, Any]]) -> list[str]:
|
| 752 |
+
metrics = compute_metrics(rows)
|
| 753 |
+
currency = metrics["currency"]
|
| 754 |
+
flags = []
|
| 755 |
+
if metrics["due_income"] > metrics["paid_income"] and metrics["due_income"] > 0:
|
| 756 |
+
flags.append(f"Due income ({money(metrics['due_income'], currency)}) is higher than collected cash.")
|
| 757 |
+
for row in rows:
|
| 758 |
+
if row.get("payment_status") == "due" and amount(row) >= 5000:
|
| 759 |
+
flags.append(f"High-value due item: {row.get('counterparty') or 'Unknown'} owes {money(amount(row), currency)}.")
|
| 760 |
+
if metrics["avg_confidence"] and metrics["avg_confidence"] < 0.55:
|
| 761 |
+
flags.append("Average extraction confidence is low. Review recent rows before using the CSV.")
|
| 762 |
+
return flags[:5]
|
| 763 |
+
|
| 764 |
+
|
| 765 |
+
def daily_field_note(rows: list[dict[str, Any]]) -> str:
|
| 766 |
+
metrics = compute_metrics(rows)
|
| 767 |
+
currency = metrics["currency"]
|
| 768 |
+
categories = [item["category"] for item in category_breakdown(rows)[:2]]
|
| 769 |
+
category_text = " and ".join(categories) if categories else "general trade"
|
| 770 |
+
return (
|
| 771 |
+
f"{date.today().isoformat()}: {len(rows)} entries logged. "
|
| 772 |
+
f"Money moved mostly through {category_text}. "
|
| 773 |
+
f"Net cash is {money(metrics['net_cash'], currency)} with {metrics['open_followups']} follow-up(s) open."
|
| 774 |
+
)
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
def build_reminder_markdown(rows: list[dict[str, Any]]) -> str:
|
| 778 |
+
queue = followup_rows(rows)
|
| 779 |
+
if not queue:
|
| 780 |
+
return "### Automation Queue\nNo follow-ups waiting. The desk is clear."
|
| 781 |
+
|
| 782 |
+
cards = ["### Automation Queue"]
|
| 783 |
+
for item in queue[:8]:
|
| 784 |
+
cards.append(
|
| 785 |
+
"<div class='followup-card'>"
|
| 786 |
+
f"<strong>{h(item['priority'])} · {h(item['counterparty'])} · {h(item['amount'])}</strong>"
|
| 787 |
+
f"<p>{h(item['next_action'])}</p>"
|
| 788 |
+
f"<small>Cadence: {h(item['cadence'])}</small>"
|
| 789 |
+
"<div class='reply-grid'>"
|
| 790 |
+
f"<code><span>Polite</span>{h(item['polite_script'])}</code>"
|
| 791 |
+
f"<code><span>Friendly</span>{h(item['friendly_script'])}</code>"
|
| 792 |
+
f"<code><span>Firm</span>{h(item['firm_script'])}</code>"
|
| 793 |
+
"</div>"
|
| 794 |
+
"</div>"
|
| 795 |
+
)
|
| 796 |
+
return "\n".join(cards)
|
| 797 |
+
|
| 798 |
+
|
| 799 |
+
def build_review_markdown(rows: list[dict[str, Any]]) -> str:
|
| 800 |
+
queue = review_rows(rows)
|
| 801 |
+
if not rows:
|
| 802 |
+
return "### Review Desk\nRows that need a human check will appear here."
|
| 803 |
+
if not queue:
|
| 804 |
+
return "### Review Desk\nNo low-confidence rows waiting. Export still deserves a quick glance."
|
| 805 |
+
|
| 806 |
+
cards = ["### Review Desk"]
|
| 807 |
+
for item in queue[:8]:
|
| 808 |
+
cards.append(
|
| 809 |
+
"<div class='review-card'>"
|
| 810 |
+
f"<strong>Row {h(item['source_row'])} · {h(item['issue'])} · {h(item['confidence'])}</strong>"
|
| 811 |
+
f"<p>{h(item['counterparty'])} · {h(item['item'])} · {h(item['amount'])}</p>"
|
| 812 |
+
f"<code>{h(item['question'])}</code>"
|
| 813 |
+
"</div>"
|
| 814 |
+
)
|
| 815 |
+
return "\n".join(cards)
|
| 816 |
+
|
| 817 |
+
|
| 818 |
+
def timeline_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 819 |
+
events = []
|
| 820 |
+
currency = primary_currency(rows)
|
| 821 |
+
for index, row in enumerate(rows, start=1):
|
| 822 |
+
value = amount(row)
|
| 823 |
+
direction = row.get("direction") or "unknown"
|
| 824 |
+
status = row.get("payment_status") or "unknown"
|
| 825 |
+
party = row.get("counterparty") or "Unknown"
|
| 826 |
+
item = row.get("item") or "ledger item"
|
| 827 |
+
signed = value
|
| 828 |
+
if direction == "expense":
|
| 829 |
+
signed = -value
|
| 830 |
+
elif status == "due":
|
| 831 |
+
signed = 0
|
| 832 |
+
badge = "Cash in" if direction == "income" and status == "paid" else "Cash out" if direction == "expense" else "Due" if status == "due" else "Logged"
|
| 833 |
+
story = f"{badge}: {party} · {item} · {money(value, currency)}"
|
| 834 |
+
events.append(
|
| 835 |
+
{
|
| 836 |
+
"source_row": index,
|
| 837 |
+
"date": str(row.get("date") or date.today().isoformat()),
|
| 838 |
+
"badge": badge,
|
| 839 |
+
"direction": direction,
|
| 840 |
+
"counterparty": party,
|
| 841 |
+
"item": item,
|
| 842 |
+
"amount": money(value, currency),
|
| 843 |
+
"signed_amount": signed,
|
| 844 |
+
"status": status,
|
| 845 |
+
"story": story,
|
| 846 |
+
}
|
| 847 |
+
)
|
| 848 |
+
return sorted(events, key=lambda event: (parse_row_date(event["date"]), event["source_row"]))
|
| 849 |
+
|
| 850 |
+
|
| 851 |
+
def build_timeline_markdown(rows: list[dict[str, Any]]) -> str:
|
| 852 |
+
events = timeline_rows(rows)
|
| 853 |
+
if not events:
|
| 854 |
+
return "### Shop Pulse Timeline\nThe day's story appears after the first ledger entry."
|
| 855 |
+
|
| 856 |
+
cards = ["### Shop Pulse Timeline", "<div class='timeline-rail'>"]
|
| 857 |
+
for event in events[:12]:
|
| 858 |
+
tone = "income" if event["direction"] == "income" else "expense" if event["direction"] == "expense" else "due"
|
| 859 |
+
cards.append(
|
| 860 |
+
f"<div class='timeline-card {tone}'>"
|
| 861 |
+
f"<strong>Row {h(event['source_row'])} · {h(event['badge'])} · {h(event['date'])}</strong>"
|
| 862 |
+
f"<p>{h(event['counterparty'])} · {h(event['item'])}</p>"
|
| 863 |
+
f"<code>{h(event['amount'])} · {h(event['status'])}</code>"
|
| 864 |
+
"</div>"
|
| 865 |
+
)
|
| 866 |
+
cards.append("</div>")
|
| 867 |
+
return "\n".join(cards)
|
| 868 |
+
|
| 869 |
+
|
| 870 |
+
def timeline_figure(rows: list[dict[str, Any]]) -> go.Figure:
|
| 871 |
+
events = timeline_rows(rows)
|
| 872 |
+
figure = base_figure("Shop pulse timeline", "Rows become the story of the day")
|
| 873 |
+
if not events:
|
| 874 |
+
return empty_figure()
|
| 875 |
+
|
| 876 |
+
labels = [f"Row {event['source_row']}" for event in events]
|
| 877 |
+
values = [event["signed_amount"] for event in events]
|
| 878 |
+
colors = [
|
| 879 |
+
PALETTE["green"] if event["direction"] == "income" and event["status"] == "paid"
|
| 880 |
+
else PALETTE["red"] if event["direction"] == "expense"
|
| 881 |
+
else PALETTE["gold"] if event["status"] == "due"
|
| 882 |
+
else PALETTE["blue"]
|
| 883 |
+
for event in events
|
| 884 |
+
]
|
| 885 |
+
hover = [event["story"] for event in events]
|
| 886 |
+
figure.add_trace(
|
| 887 |
+
go.Bar(
|
| 888 |
+
x=labels,
|
| 889 |
+
y=values,
|
| 890 |
+
marker={"color": colors},
|
| 891 |
+
marker_line={"color": "rgba(243, 244, 236, 0.28)", "width": 1},
|
| 892 |
+
text=[event["badge"] for event in events],
|
| 893 |
+
textposition="auto",
|
| 894 |
+
hovertext=hover,
|
| 895 |
+
hovertemplate="%{hovertext}<extra></extra>",
|
| 896 |
+
)
|
| 897 |
+
)
|
| 898 |
+
figure.add_hline(y=0, line_color="rgba(243, 244, 236, 0.28)", line_width=1)
|
| 899 |
+
return figure
|
| 900 |
+
|
| 901 |
+
|
| 902 |
+
def build_tables(
|
| 903 |
+
rows: list[dict[str, Any]],
|
| 904 |
+
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
| 905 |
+
return category_breakdown(rows), party_breakdown(rows), followup_rows(rows), review_rows(rows)
|
| 906 |
+
|
| 907 |
+
|
| 908 |
+
def base_figure(title: str, subtitle: str = "") -> go.Figure:
|
| 909 |
+
figure = go.Figure()
|
| 910 |
+
figure.update_layout(
|
| 911 |
+
title={
|
| 912 |
+
"text": title if not subtitle else f"{title}<br><sup>{subtitle}</sup>",
|
| 913 |
+
"x": 0.02,
|
| 914 |
+
"xanchor": "left",
|
| 915 |
+
"font": {"size": 17, "color": PALETTE["ink"]},
|
| 916 |
+
},
|
| 917 |
+
paper_bgcolor=PALETTE["bg"],
|
| 918 |
+
plot_bgcolor=PALETTE["plot"],
|
| 919 |
+
font={"color": PALETTE["ink"], "family": "Inter, ui-sans-serif, system-ui, sans-serif", "size": 12},
|
| 920 |
+
margin={"l": 48, "r": 24, "t": 78, "b": 48},
|
| 921 |
+
height=350,
|
| 922 |
+
legend={
|
| 923 |
+
"orientation": "h",
|
| 924 |
+
"y": -0.22,
|
| 925 |
+
"x": 0,
|
| 926 |
+
"font": {"color": PALETTE["muted"], "size": 11},
|
| 927 |
+
"bgcolor": "rgba(8, 12, 18, 0)",
|
| 928 |
+
},
|
| 929 |
+
hoverlabel={
|
| 930 |
+
"bgcolor": "#10151d",
|
| 931 |
+
"bordercolor": "rgba(157, 177, 154, 0.32)",
|
| 932 |
+
"font": {"color": PALETTE["ink"], "size": 12},
|
| 933 |
+
},
|
| 934 |
+
bargap=0.34,
|
| 935 |
+
transition={"duration": 240, "easing": "cubic-in-out"},
|
| 936 |
+
)
|
| 937 |
+
figure.update_xaxes(
|
| 938 |
+
gridcolor=PALETTE["grid"],
|
| 939 |
+
zerolinecolor=PALETTE["grid"],
|
| 940 |
+
linecolor="rgba(157, 177, 154, 0.22)",
|
| 941 |
+
tickfont={"color": PALETTE["axis"], "size": 11},
|
| 942 |
+
title_font={"color": PALETTE["muted"], "size": 11},
|
| 943 |
+
ticks="outside",
|
| 944 |
+
tickcolor="rgba(157, 177, 154, 0.22)",
|
| 945 |
+
)
|
| 946 |
+
figure.update_yaxes(
|
| 947 |
+
gridcolor=PALETTE["grid"],
|
| 948 |
+
zerolinecolor=PALETTE["grid"],
|
| 949 |
+
linecolor="rgba(157, 177, 154, 0.22)",
|
| 950 |
+
tickfont={"color": PALETTE["axis"], "size": 11},
|
| 951 |
+
title_font={"color": PALETTE["muted"], "size": 11},
|
| 952 |
+
ticks="outside",
|
| 953 |
+
tickcolor="rgba(157, 177, 154, 0.22)",
|
| 954 |
+
)
|
| 955 |
+
return figure
|
| 956 |
+
|
| 957 |
+
|
| 958 |
+
def empty_figure() -> go.Figure:
|
| 959 |
+
figure = base_figure("Ledger signal board", "Add a note to generate the first graph")
|
| 960 |
+
figure.add_annotation(
|
| 961 |
+
text="No rows yet",
|
| 962 |
+
x=0.5,
|
| 963 |
+
y=0.5,
|
| 964 |
+
showarrow=False,
|
| 965 |
+
font={"size": 24, "color": PALETTE["muted"]},
|
| 966 |
+
xref="paper",
|
| 967 |
+
yref="paper",
|
| 968 |
+
)
|
| 969 |
+
figure.update_xaxes(visible=False)
|
| 970 |
+
figure.update_yaxes(visible=False)
|
| 971 |
+
return figure
|
| 972 |
+
|
| 973 |
+
|
| 974 |
+
def due_by_party_figure(rows: list[dict[str, Any]]) -> go.Figure:
|
| 975 |
+
currency = primary_currency(rows)
|
| 976 |
+
totals: dict[str, float] = defaultdict(float)
|
| 977 |
+
for row in rows:
|
| 978 |
+
if row.get("payment_status") == "due":
|
| 979 |
+
totals[str(row.get("counterparty") or "Unknown")] += amount(row)
|
| 980 |
+
items = sorted(totals.items(), key=lambda item: item[1], reverse=True)[:8]
|
| 981 |
+
figure = base_figure("Due radar", "Highest-value follow-ups first")
|
| 982 |
+
if not items:
|
| 983 |
+
return empty_figure()
|
| 984 |
+
parties, values = zip(*items)
|
| 985 |
+
figure.add_trace(
|
| 986 |
+
go.Bar(
|
| 987 |
+
x=list(values),
|
| 988 |
+
y=list(parties),
|
| 989 |
+
orientation="h",
|
| 990 |
+
marker={"color": PALETTE["gold"], "line": {"color": "rgba(243, 244, 236, 0.22)", "width": 1}},
|
| 991 |
+
opacity=0.94,
|
| 992 |
+
text=[money(value, currency) for value in values],
|
| 993 |
+
textposition="auto",
|
| 994 |
+
hovertemplate="%{y}<br>%{text}<extra></extra>",
|
| 995 |
+
)
|
| 996 |
+
)
|
| 997 |
+
figure.update_yaxes(autorange="reversed")
|
| 998 |
+
return figure
|
| 999 |
+
|
| 1000 |
+
|
| 1001 |
+
def expense_category_figure(rows: list[dict[str, Any]]) -> go.Figure:
|
| 1002 |
+
currency = primary_currency(rows)
|
| 1003 |
+
totals: dict[str, float] = defaultdict(float)
|
| 1004 |
+
for row in rows:
|
| 1005 |
+
if row.get("direction") == "expense":
|
| 1006 |
+
totals[str(row.get("category") or "uncategorized")] += amount(row)
|
| 1007 |
+
items = sorted(totals.items(), key=lambda item: item[1], reverse=True)[:8]
|
| 1008 |
+
figure = base_figure("Spend pressure", "Expense categories ranked by amount")
|
| 1009 |
+
if not items:
|
| 1010 |
+
return empty_figure()
|
| 1011 |
+
categories, values = zip(*items)
|
| 1012 |
+
figure.add_trace(
|
| 1013 |
+
go.Bar(
|
| 1014 |
+
x=list(categories),
|
| 1015 |
+
y=list(values),
|
| 1016 |
+
marker={
|
| 1017 |
+
"color": [PALETTE["red"], PALETTE["gold"], PALETTE["blue"], PALETTE["violet"]] * 2,
|
| 1018 |
+
"line": {"color": "rgba(243, 244, 236, 0.22)", "width": 1},
|
| 1019 |
+
},
|
| 1020 |
+
opacity=0.94,
|
| 1021 |
+
text=[money(value, currency) for value in values],
|
| 1022 |
+
textposition="auto",
|
| 1023 |
+
hovertemplate="%{x}<br>%{text}<extra></extra>",
|
| 1024 |
+
)
|
| 1025 |
+
)
|
| 1026 |
+
return figure
|
| 1027 |
+
|
| 1028 |
+
|
| 1029 |
+
def cashflow_figure(rows: list[dict[str, Any]]) -> go.Figure:
|
| 1030 |
+
currency = primary_currency(rows)
|
| 1031 |
+
income: dict[date, float] = defaultdict(float)
|
| 1032 |
+
expense: dict[date, float] = defaultdict(float)
|
| 1033 |
+
for row in rows:
|
| 1034 |
+
day = parse_row_date(row.get("date"))
|
| 1035 |
+
if row.get("direction") == "income" and row.get("payment_status") == "paid":
|
| 1036 |
+
income[day] += amount(row)
|
| 1037 |
+
if row.get("direction") == "expense" and row.get("payment_status") == "paid":
|
| 1038 |
+
expense[day] += amount(row)
|
| 1039 |
+
days = sorted(set(income) | set(expense))
|
| 1040 |
+
figure = base_figure("Cashflow trail", "Paid income and expenses by date")
|
| 1041 |
+
if not days:
|
| 1042 |
+
return empty_figure()
|
| 1043 |
+
labels = [day.isoformat() for day in days]
|
| 1044 |
+
income_values = [income[day] for day in days]
|
| 1045 |
+
expense_values = [-expense[day] for day in days]
|
| 1046 |
+
net_values = [income[day] - expense[day] for day in days]
|
| 1047 |
+
figure.add_trace(
|
| 1048 |
+
go.Bar(
|
| 1049 |
+
name="Cash in",
|
| 1050 |
+
x=labels,
|
| 1051 |
+
y=income_values,
|
| 1052 |
+
marker={"color": PALETTE["green"], "line": {"color": "rgba(243, 244, 236, 0.18)", "width": 1}},
|
| 1053 |
+
opacity=0.92,
|
| 1054 |
+
)
|
| 1055 |
+
)
|
| 1056 |
+
figure.add_trace(
|
| 1057 |
+
go.Bar(
|
| 1058 |
+
name="Cash out",
|
| 1059 |
+
x=labels,
|
| 1060 |
+
y=expense_values,
|
| 1061 |
+
marker={"color": PALETTE["red"], "line": {"color": "rgba(243, 244, 236, 0.18)", "width": 1}},
|
| 1062 |
+
opacity=0.92,
|
| 1063 |
+
)
|
| 1064 |
+
)
|
| 1065 |
+
figure.add_trace(
|
| 1066 |
+
go.Scatter(
|
| 1067 |
+
name="Net",
|
| 1068 |
+
x=labels,
|
| 1069 |
+
y=net_values,
|
| 1070 |
+
mode="lines+markers",
|
| 1071 |
+
line={"color": PALETTE["blue"], "width": 3},
|
| 1072 |
+
marker={"size": 9, "color": PALETTE["blue"], "line": {"color": PALETTE["bg"], "width": 2}},
|
| 1073 |
+
hovertemplate=f"%{{x}}<br>{currency} %{{y:,.0f}}<extra></extra>",
|
| 1074 |
+
)
|
| 1075 |
+
)
|
| 1076 |
+
figure.update_layout(barmode="relative")
|
| 1077 |
+
return figure
|
| 1078 |
+
|
| 1079 |
+
|
| 1080 |
+
def confidence_review_figure(rows: list[dict[str, Any]]) -> go.Figure:
|
| 1081 |
+
figure = base_figure("Review queue", "Lower bars deserve a quick check")
|
| 1082 |
+
labels = [f"Row {index}" for index, _ in enumerate(rows, start=1)]
|
| 1083 |
+
values = [float(row.get("confidence") or 0) for row in rows]
|
| 1084 |
+
colors = [PALETTE["red"] if value < 0.6 else PALETTE["green"] for value in values]
|
| 1085 |
+
figure.add_trace(
|
| 1086 |
+
go.Bar(
|
| 1087 |
+
x=labels,
|
| 1088 |
+
y=values,
|
| 1089 |
+
marker={"color": colors, "line": {"color": "rgba(243, 244, 236, 0.22)", "width": 1}},
|
| 1090 |
+
opacity=0.94,
|
| 1091 |
+
text=[f"{value:.0%}" for value in values],
|
| 1092 |
+
textposition="auto",
|
| 1093 |
+
hovertext=[row.get("item") or "ledger item" for row in rows],
|
| 1094 |
+
hovertemplate="%{x}<br>%{hovertext}<br>%{text}<extra></extra>",
|
| 1095 |
+
)
|
| 1096 |
+
)
|
| 1097 |
+
figure.update_yaxes(range=[0, 1], tickformat=".0%")
|
| 1098 |
+
return figure
|
| 1099 |
+
|
| 1100 |
+
|
| 1101 |
+
def category_mix_figure(rows: list[dict[str, Any]]) -> go.Figure:
|
| 1102 |
+
breakdown = category_breakdown(rows)
|
| 1103 |
+
figure = base_figure("Category mix", "A quick map of where money moved")
|
| 1104 |
+
if not breakdown:
|
| 1105 |
+
return empty_figure()
|
| 1106 |
+
figure.add_trace(
|
| 1107 |
+
go.Pie(
|
| 1108 |
+
labels=[item["category"] for item in breakdown[:8]],
|
| 1109 |
+
values=[item["total"] for item in breakdown[:8]],
|
| 1110 |
+
hole=0.55,
|
| 1111 |
+
marker={
|
| 1112 |
+
"colors": [PALETTE["green"], PALETTE["gold"], PALETTE["blue"], PALETTE["red"], PALETTE["violet"]],
|
| 1113 |
+
"line": {"color": PALETTE["bg"], "width": 2},
|
| 1114 |
+
},
|
| 1115 |
+
textinfo="label+percent",
|
| 1116 |
+
textfont={"color": PALETTE["ink"], "size": 12},
|
| 1117 |
+
hovertemplate="%{label}<br>%{value:,.0f}<extra></extra>",
|
| 1118 |
+
)
|
| 1119 |
+
)
|
| 1120 |
+
figure.update_xaxes(visible=False)
|
| 1121 |
+
figure.update_yaxes(visible=False)
|
| 1122 |
+
return figure
|
| 1123 |
+
|
| 1124 |
+
|
| 1125 |
+
def party_exposure_figure(rows: list[dict[str, Any]]) -> go.Figure:
|
| 1126 |
+
currency = primary_currency(rows)
|
| 1127 |
+
totals: dict[str, float] = defaultdict(float)
|
| 1128 |
+
due: dict[str, float] = defaultdict(float)
|
| 1129 |
+
for row in rows:
|
| 1130 |
+
party = str(row.get("counterparty") or "Unknown")
|
| 1131 |
+
totals[party] += amount(row)
|
| 1132 |
+
if row.get("payment_status") == "due":
|
| 1133 |
+
due[party] += amount(row)
|
| 1134 |
+
items = sorted(totals.items(), key=lambda item: item[1], reverse=True)[:8]
|
| 1135 |
+
figure = base_figure("People ledger", "Total movement vs unpaid exposure")
|
| 1136 |
+
if not items:
|
| 1137 |
+
return empty_figure()
|
| 1138 |
+
parties = [party for party, _ in items]
|
| 1139 |
+
figure.add_trace(
|
| 1140 |
+
go.Bar(
|
| 1141 |
+
name="Total",
|
| 1142 |
+
x=parties,
|
| 1143 |
+
y=[totals[party] for party in parties],
|
| 1144 |
+
marker={"color": PALETTE["blue"], "line": {"color": "rgba(243, 244, 236, 0.18)", "width": 1}},
|
| 1145 |
+
opacity=0.92,
|
| 1146 |
+
hovertemplate=f"%{{x}}<br>{currency} %{{y:,.0f}} total<extra></extra>",
|
| 1147 |
+
)
|
| 1148 |
+
)
|
| 1149 |
+
figure.add_trace(
|
| 1150 |
+
go.Bar(
|
| 1151 |
+
name="Due",
|
| 1152 |
+
x=parties,
|
| 1153 |
+
y=[due[party] for party in parties],
|
| 1154 |
+
marker={"color": PALETTE["gold"], "line": {"color": "rgba(243, 244, 236, 0.18)", "width": 1}},
|
| 1155 |
+
opacity=0.92,
|
| 1156 |
+
hovertemplate=f"%{{x}}<br>{currency} %{{y:,.0f}} due<extra></extra>",
|
| 1157 |
+
)
|
| 1158 |
+
)
|
| 1159 |
+
figure.update_layout(barmode="group")
|
| 1160 |
+
return figure
|
| 1161 |
+
|
| 1162 |
+
|
| 1163 |
+
def build_insight_figures(rows: list[dict[str, Any]]) -> tuple[go.Figure, go.Figure, go.Figure]:
|
| 1164 |
+
if not rows:
|
| 1165 |
+
empty = empty_figure()
|
| 1166 |
+
return empty, empty_figure(), empty_figure()
|
| 1167 |
+
|
| 1168 |
+
plan = build_chart_plan(rows)
|
| 1169 |
+
primary_builders = {
|
| 1170 |
+
"due_by_party": due_by_party_figure,
|
| 1171 |
+
"expense_categories": expense_category_figure,
|
| 1172 |
+
"cashflow": cashflow_figure,
|
| 1173 |
+
"confidence_review": confidence_review_figure,
|
| 1174 |
+
"category_mix": category_mix_figure,
|
| 1175 |
+
}
|
| 1176 |
+
primary = primary_builders.get(plan["chart"], category_mix_figure)(rows)
|
| 1177 |
+
return primary, cashflow_figure(rows), party_exposure_figure(rows)
|
shop_ledger/llama_backend.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from shop_ledger.heuristics import heuristic_extract
|
| 10 |
+
from shop_ledger.schema import LedgerResult
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
SYSTEM_PROMPT = """You turn messy shopkeeper notes, receipts, bills, and ledger images into a clean ledger.
|
| 14 |
+
Return only valid JSON with this exact shape:
|
| 15 |
+
{
|
| 16 |
+
"entries": [
|
| 17 |
+
{
|
| 18 |
+
"date": "YYYY-MM-DD or empty",
|
| 19 |
+
"direction": "expense|income|transfer|unknown",
|
| 20 |
+
"counterparty": "person or business",
|
| 21 |
+
"item": "what changed hands",
|
| 22 |
+
"quantity": "quantity if known",
|
| 23 |
+
"amount": 0,
|
| 24 |
+
"currency": "LKR",
|
| 25 |
+
"category": "inventory|utilities|rent|wages|transport|maintenance|sales|general expense|uncategorized",
|
| 26 |
+
"payment_status": "paid|due|partial|unknown",
|
| 27 |
+
"due_date": "",
|
| 28 |
+
"reminder": "short follow-up reminder or empty",
|
| 29 |
+
"confidence": 0.0,
|
| 30 |
+
"original_note": "source fragment"
|
| 31 |
+
}
|
| 32 |
+
],
|
| 33 |
+
"reminders": ["short reminders"],
|
| 34 |
+
"questions": ["only ask if an amount, person, or due date is unclear"],
|
| 35 |
+
"cleaned_note": "normalized note"
|
| 36 |
+
}
|
| 37 |
+
Use the user's currency. Do not invent amounts. Split multiple transactions into multiple entries.
|
| 38 |
+
For images, read visible receipt/bill/note text and infer only clear ledger facts."""
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class LlamaLedgerBackend:
|
| 42 |
+
def __init__(
|
| 43 |
+
self,
|
| 44 |
+
model_path: str | None = None,
|
| 45 |
+
n_ctx: int | None = None,
|
| 46 |
+
n_threads: int | None = None,
|
| 47 |
+
n_gpu_layers: int | None = None,
|
| 48 |
+
) -> None:
|
| 49 |
+
self.model_path = model_path or os.getenv("LLAMA_GGUF_PATH", "")
|
| 50 |
+
self.model_label = os.getenv("LLAMA_MODEL_LABEL", "").strip() or self._default_model_label()
|
| 51 |
+
self.n_ctx = n_ctx or int(os.getenv("LLAMA_N_CTX", "4096"))
|
| 52 |
+
self.n_threads = n_threads or max(2, (os.cpu_count() or 4) - 1)
|
| 53 |
+
self.n_gpu_layers = n_gpu_layers if n_gpu_layers is not None else int(os.getenv("LLAMA_N_GPU_LAYERS", "0"))
|
| 54 |
+
self._llm: Any | None = None
|
| 55 |
+
|
| 56 |
+
def _default_model_label(self) -> str:
|
| 57 |
+
model_file = Path(self.model_path).name if self.model_path else "GGUF model"
|
| 58 |
+
return f"llama.cpp / {model_file}"
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def available(self) -> bool:
|
| 62 |
+
return bool(self.model_path and Path(self.model_path).exists())
|
| 63 |
+
|
| 64 |
+
def load(self) -> None:
|
| 65 |
+
if self._llm is not None:
|
| 66 |
+
return
|
| 67 |
+
if not self.available:
|
| 68 |
+
raise FileNotFoundError(f"GGUF model not found: {self.model_path}")
|
| 69 |
+
|
| 70 |
+
from llama_cpp import Llama
|
| 71 |
+
|
| 72 |
+
self._llm = Llama(
|
| 73 |
+
model_path=self.model_path,
|
| 74 |
+
n_ctx=self.n_ctx,
|
| 75 |
+
n_threads=self.n_threads,
|
| 76 |
+
n_gpu_layers=self.n_gpu_layers,
|
| 77 |
+
verbose=False,
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
def extract(self, note: str, currency: str = "LKR", image_urls: list[str] | None = None) -> LedgerResult:
|
| 81 |
+
if not self.available:
|
| 82 |
+
return heuristic_extract(note, currency=currency)
|
| 83 |
+
|
| 84 |
+
self.load()
|
| 85 |
+
assert self._llm is not None
|
| 86 |
+
image_urls = image_urls or []
|
| 87 |
+
user_content: str | list[dict[str, Any]]
|
| 88 |
+
prompt = f"Currency: {currency}\nNote or document context:\n{note or 'Read the uploaded document image(s).'}"
|
| 89 |
+
if image_urls:
|
| 90 |
+
user_content = [{"type": "text", "text": prompt}]
|
| 91 |
+
user_content.extend({"type": "image_url", "image_url": {"url": image_url}} for image_url in image_urls)
|
| 92 |
+
else:
|
| 93 |
+
user_content = prompt
|
| 94 |
+
|
| 95 |
+
response = self._llm.create_chat_completion(
|
| 96 |
+
messages=[
|
| 97 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 98 |
+
{"role": "user", "content": user_content},
|
| 99 |
+
],
|
| 100 |
+
max_tokens=900,
|
| 101 |
+
temperature=0.1,
|
| 102 |
+
top_p=0.9,
|
| 103 |
+
)
|
| 104 |
+
text = response["choices"][0]["message"]["content"]
|
| 105 |
+
data = parse_json_object(text)
|
| 106 |
+
result = LedgerResult.model_validate(data)
|
| 107 |
+
result.model_used = self.model_label
|
| 108 |
+
return result
|
| 109 |
+
|
| 110 |
+
def daily_brief(self, rows: list[dict[str, Any]], currency: str = "LKR") -> str:
|
| 111 |
+
if not self.available:
|
| 112 |
+
return ""
|
| 113 |
+
|
| 114 |
+
self.load()
|
| 115 |
+
assert self._llm is not None
|
| 116 |
+
|
| 117 |
+
response = self._llm.create_chat_completion(
|
| 118 |
+
messages=[
|
| 119 |
+
{
|
| 120 |
+
"role": "system",
|
| 121 |
+
"content": (
|
| 122 |
+
"You write a short shopkeeper daily brief from structured ledger rows. "
|
| 123 |
+
"Be specific, practical, and under 80 words. Mention cash position, dues, "
|
| 124 |
+
"largest pressure, and the next follow-up when relevant."
|
| 125 |
+
),
|
| 126 |
+
},
|
| 127 |
+
{"role": "user", "content": f"Currency: {currency}\nRows JSON:\n{json.dumps(rows, ensure_ascii=True)}"},
|
| 128 |
+
],
|
| 129 |
+
max_tokens=180,
|
| 130 |
+
temperature=0.3,
|
| 131 |
+
top_p=0.9,
|
| 132 |
+
)
|
| 133 |
+
return str(response["choices"][0]["message"]["content"]).strip()
|
| 134 |
+
|
| 135 |
+
def answer_ledger_question(self, rows: list[dict[str, Any]], question: str, currency: str = "LKR") -> str:
|
| 136 |
+
if not self.available:
|
| 137 |
+
return ""
|
| 138 |
+
|
| 139 |
+
self.load()
|
| 140 |
+
assert self._llm is not None
|
| 141 |
+
|
| 142 |
+
response = self._llm.create_chat_completion(
|
| 143 |
+
messages=[
|
| 144 |
+
{
|
| 145 |
+
"role": "system",
|
| 146 |
+
"content": (
|
| 147 |
+
"Answer questions about a small shop ledger using only the provided structured rows. "
|
| 148 |
+
"Be concise, practical, and mention amounts/counterparties when relevant. "
|
| 149 |
+
"If the rows do not contain the answer, say what is missing."
|
| 150 |
+
),
|
| 151 |
+
},
|
| 152 |
+
{
|
| 153 |
+
"role": "user",
|
| 154 |
+
"content": (
|
| 155 |
+
f"Currency: {currency}\nQuestion: {question}\nRows JSON:\n"
|
| 156 |
+
f"{json.dumps(rows, ensure_ascii=True)}"
|
| 157 |
+
),
|
| 158 |
+
},
|
| 159 |
+
],
|
| 160 |
+
max_tokens=220,
|
| 161 |
+
temperature=0.2,
|
| 162 |
+
top_p=0.9,
|
| 163 |
+
)
|
| 164 |
+
return str(response["choices"][0]["message"]["content"]).strip()
|
| 165 |
+
|
| 166 |
+
def choose_chart_spec(self, rows: list[dict[str, Any]], question: str, allowed_charts: dict[str, str]) -> dict[str, str]:
|
| 167 |
+
if not self.available:
|
| 168 |
+
return {}
|
| 169 |
+
|
| 170 |
+
self.load()
|
| 171 |
+
assert self._llm is not None
|
| 172 |
+
|
| 173 |
+
response = self._llm.create_chat_completion(
|
| 174 |
+
messages=[
|
| 175 |
+
{
|
| 176 |
+
"role": "system",
|
| 177 |
+
"content": (
|
| 178 |
+
"Choose the best chart for a small shop ledger question. "
|
| 179 |
+
"Return only JSON with keys chart and reason. "
|
| 180 |
+
f"Allowed chart ids: {', '.join(allowed_charts)}."
|
| 181 |
+
),
|
| 182 |
+
},
|
| 183 |
+
{
|
| 184 |
+
"role": "user",
|
| 185 |
+
"content": (
|
| 186 |
+
f"Question: {question}\nAllowed charts: {json.dumps(allowed_charts, ensure_ascii=True)}\n"
|
| 187 |
+
f"Rows JSON:\n{json.dumps(rows, ensure_ascii=True)}"
|
| 188 |
+
),
|
| 189 |
+
},
|
| 190 |
+
],
|
| 191 |
+
max_tokens=160,
|
| 192 |
+
temperature=0.1,
|
| 193 |
+
top_p=0.9,
|
| 194 |
+
)
|
| 195 |
+
data = parse_json_object(str(response["choices"][0]["message"]["content"]))
|
| 196 |
+
return {"chart": str(data.get("chart") or ""), "reason": str(data.get("reason") or "")}
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def parse_json_object(text: str) -> dict[str, Any]:
|
| 200 |
+
try:
|
| 201 |
+
return json.loads(text)
|
| 202 |
+
except json.JSONDecodeError:
|
| 203 |
+
pass
|
| 204 |
+
|
| 205 |
+
match = re.search(r"\{.*\}", text, flags=re.DOTALL)
|
| 206 |
+
if not match:
|
| 207 |
+
raise ValueError(f"Model did not return JSON: {text[:240]}")
|
| 208 |
+
return json.loads(match.group(0))
|
shop_ledger/processor.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import base64
|
| 5 |
+
from io import BytesIO
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
from shop_ledger.heuristics import heuristic_extract
|
| 10 |
+
from shop_ledger.insights import CHART_SPECS, answer_ledger_question, chart_spec_from_question, daily_brief_fallback
|
| 11 |
+
from shop_ledger.llama_backend import LlamaLedgerBackend
|
| 12 |
+
from shop_ledger.schema import LedgerResult
|
| 13 |
+
|
| 14 |
+
_WHISPER_MODELS: dict[str, Any] = {}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class LedgerProcessor:
|
| 18 |
+
def __init__(self, mode: str = "mock", model_path: str | None = None) -> None:
|
| 19 |
+
self.mode = mode
|
| 20 |
+
self.backend = LlamaLedgerBackend(model_path=model_path)
|
| 21 |
+
|
| 22 |
+
@classmethod
|
| 23 |
+
def from_env(cls) -> "LedgerProcessor":
|
| 24 |
+
mode = os.getenv("LEDGER_MODEL_MODE", "mock").strip().lower()
|
| 25 |
+
return cls(mode=mode, model_path=resolve_model_path_from_env())
|
| 26 |
+
|
| 27 |
+
def process(self, note: str, currency: str = "LKR", image_urls: list[str] | None = None) -> LedgerResult:
|
| 28 |
+
if self.mode == "llama":
|
| 29 |
+
if not self.backend.available:
|
| 30 |
+
fallback = heuristic_extract(note, currency=currency)
|
| 31 |
+
fallback.model_used = "heuristic fallback (missing GGUF model)"
|
| 32 |
+
fallback.questions.append("No GGUF model was found, so heuristics were used.")
|
| 33 |
+
if image_urls:
|
| 34 |
+
fallback.questions.append("Document images need the multimodal GGUF model.")
|
| 35 |
+
return fallback
|
| 36 |
+
try:
|
| 37 |
+
return self.backend.extract(note, currency=currency, image_urls=image_urls)
|
| 38 |
+
except Exception as exc:
|
| 39 |
+
fallback = heuristic_extract(note, currency=currency)
|
| 40 |
+
fallback.model_used = f"heuristic fallback ({type(exc).__name__})"
|
| 41 |
+
fallback.questions.append(f"The llama.cpp model was unavailable, so heuristics were used: {exc}")
|
| 42 |
+
if image_urls:
|
| 43 |
+
fallback.questions.append("Document images were not analyzed because multimodal inference failed.")
|
| 44 |
+
return fallback
|
| 45 |
+
result = heuristic_extract(note, currency=currency)
|
| 46 |
+
result.model_used = "mock heuristic"
|
| 47 |
+
if image_urls:
|
| 48 |
+
result.questions.append("Document images need llama.cpp multimodal mode; mock mode only reads extracted text.")
|
| 49 |
+
return result
|
| 50 |
+
|
| 51 |
+
def daily_brief(self, rows: list[dict[str, Any]], currency: str = "LKR") -> dict[str, str]:
|
| 52 |
+
if not rows:
|
| 53 |
+
return {"brief": "Add a few entries, then ask Gemma for the day's pulse.", "model_used": "local rules"}
|
| 54 |
+
if self.mode == "llama" and self.backend.available:
|
| 55 |
+
try:
|
| 56 |
+
brief = self.backend.daily_brief(rows, currency=currency)
|
| 57 |
+
if brief:
|
| 58 |
+
return {"brief": brief, "model_used": self.backend.model_label}
|
| 59 |
+
except Exception as exc:
|
| 60 |
+
return {
|
| 61 |
+
"brief": daily_brief_fallback(rows),
|
| 62 |
+
"model_used": f"local rules fallback ({type(exc).__name__})",
|
| 63 |
+
}
|
| 64 |
+
return {"brief": daily_brief_fallback(rows), "model_used": "local rules"}
|
| 65 |
+
|
| 66 |
+
def ask_ledger(self, rows: list[dict[str, Any]], question: str, currency: str = "LKR") -> dict[str, str]:
|
| 67 |
+
fallback = answer_ledger_question(rows, question)
|
| 68 |
+
if self.mode == "llama" and self.backend.available and rows and question.strip():
|
| 69 |
+
try:
|
| 70 |
+
answer = self.backend.answer_ledger_question(rows, question, currency=currency)
|
| 71 |
+
if answer:
|
| 72 |
+
return {"answer": answer, "model_used": self.backend.model_label}
|
| 73 |
+
except Exception as exc:
|
| 74 |
+
return {"answer": fallback, "model_used": f"local rules fallback ({type(exc).__name__})"}
|
| 75 |
+
return {"answer": fallback, "model_used": "local rules"}
|
| 76 |
+
|
| 77 |
+
def choose_chart(self, rows: list[dict[str, Any]], question: str) -> dict[str, str]:
|
| 78 |
+
fallback = chart_spec_from_question(rows, question)
|
| 79 |
+
if self.mode == "llama" and self.backend.available and rows:
|
| 80 |
+
try:
|
| 81 |
+
spec = self.backend.choose_chart_spec(rows, question, CHART_SPECS)
|
| 82 |
+
if spec.get("chart") in CHART_SPECS:
|
| 83 |
+
spec["model_used"] = self.backend.model_label
|
| 84 |
+
return spec
|
| 85 |
+
except Exception as exc:
|
| 86 |
+
fallback["model_used"] = f"local rules fallback ({type(exc).__name__})"
|
| 87 |
+
return fallback
|
| 88 |
+
return fallback
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def resolve_model_path_from_env() -> str | None:
|
| 92 |
+
model_path = os.getenv("LLAMA_GGUF_PATH")
|
| 93 |
+
if model_path:
|
| 94 |
+
return model_path
|
| 95 |
+
|
| 96 |
+
repo_id = os.getenv("LLAMA_GGUF_REPO", "").strip()
|
| 97 |
+
filename = os.getenv("LLAMA_GGUF_FILE", "").strip()
|
| 98 |
+
if not repo_id or not filename:
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
try:
|
| 102 |
+
from huggingface_hub import hf_hub_download
|
| 103 |
+
except Exception:
|
| 104 |
+
return None
|
| 105 |
+
|
| 106 |
+
try:
|
| 107 |
+
return hf_hub_download(repo_id=repo_id, filename=filename)
|
| 108 |
+
except Exception:
|
| 109 |
+
return None
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def transcribe_audio(audio_path: str | None) -> str:
|
| 113 |
+
if not audio_path:
|
| 114 |
+
return ""
|
| 115 |
+
|
| 116 |
+
path = Path(audio_path)
|
| 117 |
+
if not path.exists():
|
| 118 |
+
return ""
|
| 119 |
+
|
| 120 |
+
try:
|
| 121 |
+
from faster_whisper import WhisperModel
|
| 122 |
+
except Exception:
|
| 123 |
+
return ""
|
| 124 |
+
|
| 125 |
+
size = os.getenv("WHISPER_MODEL_SIZE", "tiny")
|
| 126 |
+
model = _WHISPER_MODELS.get(size)
|
| 127 |
+
if model is None:
|
| 128 |
+
model = WhisperModel(size, device="cpu", compute_type="int8")
|
| 129 |
+
_WHISPER_MODELS[size] = model
|
| 130 |
+
segments, _ = model.transcribe(str(path), beam_size=3)
|
| 131 |
+
return " ".join(segment.text.strip() for segment in segments).strip()
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def prepare_document_input(document_path: Any, max_pages: int = 3) -> dict[str, Any]:
|
| 135 |
+
path = normalize_document_path(document_path)
|
| 136 |
+
if not path or not path.exists():
|
| 137 |
+
return {"text": "", "image_urls": [], "page_count": 0, "kind": "missing"}
|
| 138 |
+
|
| 139 |
+
suffix = path.suffix.lower()
|
| 140 |
+
if suffix in {".txt", ".csv", ".md"}:
|
| 141 |
+
return {
|
| 142 |
+
"text": path.read_text(encoding="utf-8", errors="ignore").strip(),
|
| 143 |
+
"image_urls": [],
|
| 144 |
+
"page_count": 1,
|
| 145 |
+
"kind": "text",
|
| 146 |
+
}
|
| 147 |
+
if suffix == ".pdf":
|
| 148 |
+
return prepare_pdf_input(path, max_pages=max_pages)
|
| 149 |
+
if suffix in {".png", ".jpg", ".jpeg", ".webp", ".tif", ".tiff", ".bmp"}:
|
| 150 |
+
return {"text": "", "image_urls": [image_file_to_data_url(path)], "page_count": 1, "kind": "image"}
|
| 151 |
+
return {"text": "", "image_urls": [], "page_count": 0, "kind": "unsupported"}
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def extract_document_text(document_path: Any) -> str:
|
| 155 |
+
return str(prepare_document_input(document_path).get("text") or "")
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def normalize_document_path(document_path: Any) -> Path | None:
|
| 159 |
+
if not document_path:
|
| 160 |
+
return None
|
| 161 |
+
if isinstance(document_path, (list, tuple)):
|
| 162 |
+
if not document_path:
|
| 163 |
+
return None
|
| 164 |
+
return normalize_document_path(document_path[0])
|
| 165 |
+
if isinstance(document_path, dict):
|
| 166 |
+
value = document_path.get("path") or document_path.get("name")
|
| 167 |
+
return Path(value) if value else None
|
| 168 |
+
value = getattr(document_path, "name", document_path)
|
| 169 |
+
return Path(str(value)) if value else None
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def prepare_pdf_input(path: Path, max_pages: int = 3) -> dict[str, Any]:
|
| 173 |
+
try:
|
| 174 |
+
import fitz
|
| 175 |
+
except Exception:
|
| 176 |
+
return {"text": "", "image_urls": [], "page_count": 0, "kind": "pdf"}
|
| 177 |
+
|
| 178 |
+
chunks = []
|
| 179 |
+
image_urls = []
|
| 180 |
+
with fitz.open(path) as document:
|
| 181 |
+
page_indexes = range(min(len(document), max_pages))
|
| 182 |
+
for page_index in page_indexes:
|
| 183 |
+
page = document[page_index]
|
| 184 |
+
text = page.get_text("text").strip()
|
| 185 |
+
if text:
|
| 186 |
+
chunks.append(text)
|
| 187 |
+
|
| 188 |
+
for page_index in page_indexes:
|
| 189 |
+
page = document[page_index]
|
| 190 |
+
pixmap = page.get_pixmap(dpi=180)
|
| 191 |
+
image_urls.append(bytes_to_data_url(pixmap.tobytes("png"), "image/png"))
|
| 192 |
+
|
| 193 |
+
return {"text": "\n".join(chunks).strip(), "image_urls": image_urls, "page_count": len(image_urls), "kind": "pdf"}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def image_file_to_data_url(path: Path) -> str:
|
| 197 |
+
try:
|
| 198 |
+
from PIL import Image
|
| 199 |
+
except Exception:
|
| 200 |
+
mime = mime_for_path(path)
|
| 201 |
+
return bytes_to_data_url(path.read_bytes(), mime)
|
| 202 |
+
|
| 203 |
+
with Image.open(path) as image:
|
| 204 |
+
image.thumbnail((1600, 1600))
|
| 205 |
+
buffer = BytesIO()
|
| 206 |
+
image.convert("RGB").save(buffer, format="JPEG", quality=88)
|
| 207 |
+
return bytes_to_data_url(buffer.getvalue(), "image/jpeg")
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def bytes_to_data_url(data: bytes, mime: str) -> str:
|
| 211 |
+
encoded = base64.b64encode(data).decode("ascii")
|
| 212 |
+
return f"data:{mime};base64,{encoded}"
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def mime_for_path(path: Path) -> str:
|
| 216 |
+
suffix = path.suffix.lower()
|
| 217 |
+
if suffix in {".jpg", ".jpeg"}:
|
| 218 |
+
return "image/jpeg"
|
| 219 |
+
if suffix == ".webp":
|
| 220 |
+
return "image/webp"
|
| 221 |
+
if suffix in {".tif", ".tiff"}:
|
| 222 |
+
return "image/tiff"
|
| 223 |
+
if suffix == ".bmp":
|
| 224 |
+
return "image/bmp"
|
| 225 |
+
return "image/png"
|
shop_ledger/schema.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import date
|
| 4 |
+
from enum import Enum
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
from pydantic import BaseModel, Field, field_validator
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Direction(str, Enum):
|
| 11 |
+
expense = "expense"
|
| 12 |
+
income = "income"
|
| 13 |
+
transfer = "transfer"
|
| 14 |
+
unknown = "unknown"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class PaymentStatus(str, Enum):
|
| 18 |
+
paid = "paid"
|
| 19 |
+
due = "due"
|
| 20 |
+
partial = "partial"
|
| 21 |
+
unknown = "unknown"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class LedgerEntry(BaseModel):
|
| 25 |
+
date: str = Field(default_factory=lambda: date.today().isoformat())
|
| 26 |
+
direction: Direction = Direction.unknown
|
| 27 |
+
counterparty: str = ""
|
| 28 |
+
item: str = ""
|
| 29 |
+
quantity: str = ""
|
| 30 |
+
amount: float = 0.0
|
| 31 |
+
currency: str = "LKR"
|
| 32 |
+
category: str = "uncategorized"
|
| 33 |
+
payment_status: PaymentStatus = PaymentStatus.unknown
|
| 34 |
+
due_date: str = ""
|
| 35 |
+
reminder: str = ""
|
| 36 |
+
confidence: float = 0.5
|
| 37 |
+
original_note: str = ""
|
| 38 |
+
|
| 39 |
+
@field_validator(
|
| 40 |
+
"date",
|
| 41 |
+
"counterparty",
|
| 42 |
+
"item",
|
| 43 |
+
"quantity",
|
| 44 |
+
"currency",
|
| 45 |
+
"category",
|
| 46 |
+
"due_date",
|
| 47 |
+
"reminder",
|
| 48 |
+
"original_note",
|
| 49 |
+
mode="before",
|
| 50 |
+
)
|
| 51 |
+
@classmethod
|
| 52 |
+
def parse_optional_text(cls, value: Any) -> str:
|
| 53 |
+
if value is None:
|
| 54 |
+
return ""
|
| 55 |
+
return str(value)
|
| 56 |
+
|
| 57 |
+
@field_validator("amount", mode="before")
|
| 58 |
+
@classmethod
|
| 59 |
+
def parse_amount(cls, value: Any) -> float:
|
| 60 |
+
if value in (None, ""):
|
| 61 |
+
return 0.0
|
| 62 |
+
if isinstance(value, (int, float)):
|
| 63 |
+
return float(value)
|
| 64 |
+
cleaned = str(value).replace(",", "").strip()
|
| 65 |
+
try:
|
| 66 |
+
return float(cleaned)
|
| 67 |
+
except ValueError:
|
| 68 |
+
return 0.0
|
| 69 |
+
|
| 70 |
+
@field_validator("confidence", mode="before")
|
| 71 |
+
@classmethod
|
| 72 |
+
def clamp_confidence(cls, value: Any) -> float:
|
| 73 |
+
try:
|
| 74 |
+
number = float(value)
|
| 75 |
+
except (TypeError, ValueError):
|
| 76 |
+
return 0.5
|
| 77 |
+
return max(0.0, min(1.0, number))
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class LedgerResult(BaseModel):
|
| 81 |
+
entries: list[LedgerEntry] = Field(default_factory=list)
|
| 82 |
+
reminders: list[str] = Field(default_factory=list)
|
| 83 |
+
questions: list[str] = Field(default_factory=list)
|
| 84 |
+
cleaned_note: str = ""
|
| 85 |
+
model_used: str = "heuristic"
|
| 86 |
+
|
| 87 |
+
def as_rows(self) -> list[dict[str, Any]]:
|
| 88 |
+
return [entry.model_dump(mode="json") for entry in self.entries]
|
shop_ledger/ui.py
ADDED
|
@@ -0,0 +1,1615 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import csv
|
| 4 |
+
import tempfile
|
| 5 |
+
from collections.abc import Callable
|
| 6 |
+
from html import escape
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
import gradio as gr
|
| 10 |
+
import pandas as pd
|
| 11 |
+
|
| 12 |
+
from shop_ledger.insights import (
|
| 13 |
+
build_chart_markdown,
|
| 14 |
+
build_chart_composer_markdown,
|
| 15 |
+
build_counterparty_memory_markdown,
|
| 16 |
+
build_dashboard_markdown,
|
| 17 |
+
build_daily_brief_markdown,
|
| 18 |
+
build_insight_figures,
|
| 19 |
+
build_insights_markdown,
|
| 20 |
+
build_reminder_markdown,
|
| 21 |
+
build_review_markdown,
|
| 22 |
+
build_timeline_markdown,
|
| 23 |
+
build_tables,
|
| 24 |
+
counterparty_memory_cards,
|
| 25 |
+
chart_spec_from_question,
|
| 26 |
+
figure_for_chart_id,
|
| 27 |
+
COMMAND_ACTIONS,
|
| 28 |
+
anomaly_lantern_rows,
|
| 29 |
+
build_anomaly_lantern_markdown,
|
| 30 |
+
build_closing_ritual_markdown,
|
| 31 |
+
closing_checklist,
|
| 32 |
+
run_ledger_command,
|
| 33 |
+
timeline_figure,
|
| 34 |
+
timeline_rows,
|
| 35 |
+
)
|
| 36 |
+
from shop_ledger.processor import LedgerProcessor, prepare_document_input, transcribe_audio
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
ProcessFn = Callable[[str, str, list[str] | None], dict[str, Any]]
|
| 40 |
+
DailyBriefFn = Callable[[list[dict[str, Any]], str], dict[str, str]]
|
| 41 |
+
AskLedgerFn = Callable[[list[dict[str, Any]], str, str], dict[str, str]]
|
| 42 |
+
ChartComposerFn = Callable[[list[dict[str, Any]], str], dict[str, str]]
|
| 43 |
+
ChatHistory = list[dict[str, str]]
|
| 44 |
+
|
| 45 |
+
COLUMNS = [
|
| 46 |
+
"date",
|
| 47 |
+
"direction",
|
| 48 |
+
"counterparty",
|
| 49 |
+
"item",
|
| 50 |
+
"quantity",
|
| 51 |
+
"amount",
|
| 52 |
+
"currency",
|
| 53 |
+
"category",
|
| 54 |
+
"payment_status",
|
| 55 |
+
"due_date",
|
| 56 |
+
"confidence",
|
| 57 |
+
"reminder",
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
EXAMPLES = [
|
| 61 |
+
"paid Ravi 1200 for rice bags, customer Nimal owes 750 for tea packets, remind me Friday",
|
| 62 |
+
"bought milk packets 3600 cash from Sunil. sold biscuits 950 to school canteen",
|
| 63 |
+
"electric bill 8400 due next Monday, paid helper Kamal 2500 salary",
|
| 64 |
+
]
|
| 65 |
+
|
| 66 |
+
CORRECTION_FIELDS = [
|
| 67 |
+
"counterparty",
|
| 68 |
+
"item",
|
| 69 |
+
"amount",
|
| 70 |
+
"category",
|
| 71 |
+
"payment_status",
|
| 72 |
+
"direction",
|
| 73 |
+
"due_date",
|
| 74 |
+
"reminder",
|
| 75 |
+
"confidence",
|
| 76 |
+
]
|
| 77 |
+
|
| 78 |
+
CSS = """
|
| 79 |
+
:root {
|
| 80 |
+
--ledger-bg: #090b0f;
|
| 81 |
+
--ledger-panel: #10151d;
|
| 82 |
+
--ledger-panel-2: #151b25;
|
| 83 |
+
--ledger-line: rgba(157, 177, 154, 0.22);
|
| 84 |
+
--ledger-ink: #f3f4ec;
|
| 85 |
+
--ledger-muted: #a8b3a5;
|
| 86 |
+
--ledger-green: #8bdc8b;
|
| 87 |
+
--ledger-gold: #e6b450;
|
| 88 |
+
--ledger-red: #ff7a68;
|
| 89 |
+
--ledger-blue: #8ab4ff;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.gradio-container {
|
| 93 |
+
background:
|
| 94 |
+
linear-gradient(120deg, rgba(139, 220, 139, 0.08), transparent 32%),
|
| 95 |
+
linear-gradient(180deg, #090b0f 0%, #11171f 54%, #0c1015 100%);
|
| 96 |
+
color: var(--ledger-ink) !important;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.gradio-container .block,
|
| 100 |
+
.gradio-container .form,
|
| 101 |
+
.gradio-container .panel {
|
| 102 |
+
background: var(--ledger-panel) !important;
|
| 103 |
+
border-color: var(--ledger-line) !important;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.gradio-container textarea,
|
| 107 |
+
.gradio-container input,
|
| 108 |
+
.gradio-container select {
|
| 109 |
+
background: #0b1017 !important;
|
| 110 |
+
color: var(--ledger-ink) !important;
|
| 111 |
+
border-color: var(--ledger-line) !important;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.gradio-container label,
|
| 115 |
+
.gradio-container .wrap,
|
| 116 |
+
.gradio-container .prose,
|
| 117 |
+
.gradio-container p,
|
| 118 |
+
.gradio-container span {
|
| 119 |
+
color: var(--ledger-ink);
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
#hero {
|
| 123 |
+
padding: 22px 0 8px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
#hero h1 {
|
| 127 |
+
font-size: 38px;
|
| 128 |
+
line-height: 1.08;
|
| 129 |
+
margin-bottom: 8px;
|
| 130 |
+
color: var(--ledger-ink);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
#hero p {
|
| 134 |
+
color: var(--ledger-muted);
|
| 135 |
+
font-size: 16px;
|
| 136 |
+
max-width: 760px;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
#status-strip {
|
| 140 |
+
border: 1px solid var(--ledger-line);
|
| 141 |
+
background: rgba(16, 21, 29, 0.8);
|
| 142 |
+
border-radius: 8px;
|
| 143 |
+
padding: 8px 12px;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
#status-strip .prose {
|
| 147 |
+
margin: 0;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
#status-strip code {
|
| 151 |
+
white-space: normal;
|
| 152 |
+
overflow-wrap: anywhere;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
#cockpit-shell {
|
| 156 |
+
display: grid !important;
|
| 157 |
+
grid-template-columns: minmax(270px, 0.9fr) minmax(430px, 1.65fr) minmax(310px, 1fr);
|
| 158 |
+
gap: 12px;
|
| 159 |
+
align-items: start;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.cockpit-pane {
|
| 163 |
+
min-width: 0;
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
#input-dock,
|
| 167 |
+
#output-dock,
|
| 168 |
+
#pulse-core,
|
| 169 |
+
#assistant-rail,
|
| 170 |
+
#action-inbox,
|
| 171 |
+
#people-workbench,
|
| 172 |
+
#ledger-archive {
|
| 173 |
+
border: 1px solid var(--ledger-line);
|
| 174 |
+
background: rgba(16, 21, 29, 0.86);
|
| 175 |
+
border-radius: 8px;
|
| 176 |
+
padding: 14px;
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
#input-dock,
|
| 180 |
+
#assistant-rail {
|
| 181 |
+
position: sticky;
|
| 182 |
+
top: 12px;
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
#cockpit-shell h3,
|
| 186 |
+
#action-inbox h3,
|
| 187 |
+
#people-workbench h3,
|
| 188 |
+
#ledger-archive h3 {
|
| 189 |
+
margin-top: 0;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
#input-notice,
|
| 193 |
+
#correction-status {
|
| 194 |
+
min-height: 42px;
|
| 195 |
+
border-left: 4px solid var(--ledger-gold);
|
| 196 |
+
background: rgba(230, 180, 80, 0.1);
|
| 197 |
+
border-radius: 6px;
|
| 198 |
+
padding: 10px 12px;
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
.summary-card {
|
| 202 |
+
border-left: 4px solid var(--ledger-green);
|
| 203 |
+
background: rgba(139, 220, 139, 0.08);
|
| 204 |
+
border-radius: 6px;
|
| 205 |
+
padding: 12px;
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
.reminder-card {
|
| 209 |
+
border-left: 4px solid var(--ledger-blue);
|
| 210 |
+
background: rgba(138, 180, 255, 0.08);
|
| 211 |
+
border-radius: 6px;
|
| 212 |
+
padding: 12px;
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
button.primary {
|
| 216 |
+
border-radius: 8px !important;
|
| 217 |
+
background: linear-gradient(180deg, #92e693 0%, #5fbf73 100%) !important;
|
| 218 |
+
color: #07100b !important;
|
| 219 |
+
border: 0 !important;
|
| 220 |
+
font-weight: 700 !important;
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
#ledger-table {
|
| 224 |
+
border: 1px solid var(--ledger-line);
|
| 225 |
+
border-radius: 8px;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
#download-box {
|
| 229 |
+
min-height: 70px;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.metric-grid {
|
| 233 |
+
display: grid;
|
| 234 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 235 |
+
gap: 10px;
|
| 236 |
+
margin-top: 10px;
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
.metric-card {
|
| 240 |
+
background: rgba(8, 12, 18, 0.88);
|
| 241 |
+
border: 1px solid var(--ledger-line);
|
| 242 |
+
border-radius: 8px;
|
| 243 |
+
padding: 14px;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.metric-card span {
|
| 247 |
+
display: block;
|
| 248 |
+
color: var(--ledger-muted) !important;
|
| 249 |
+
font-size: 12px;
|
| 250 |
+
text-transform: uppercase;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.metric-card strong {
|
| 254 |
+
display: block;
|
| 255 |
+
color: var(--ledger-green);
|
| 256 |
+
font-size: 22px;
|
| 257 |
+
margin-top: 6px;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.dashboard-grid {
|
| 261 |
+
display: grid;
|
| 262 |
+
grid-template-columns: minmax(300px, 0.9fr) minmax(520px, 1.7fr);
|
| 263 |
+
gap: 12px;
|
| 264 |
+
align-items: start;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
.ops-stack {
|
| 268 |
+
display: grid;
|
| 269 |
+
gap: 12px;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.ops-card,
|
| 273 |
+
.chat-panel {
|
| 274 |
+
border: 1px solid var(--ledger-line);
|
| 275 |
+
background: rgba(8, 12, 18, 0.88);
|
| 276 |
+
border-radius: 8px;
|
| 277 |
+
padding: 14px;
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
#chart-director,
|
| 281 |
+
#daily-brief-panel,
|
| 282 |
+
#ask-ledger-panel {
|
| 283 |
+
min-height: 128px;
|
| 284 |
+
border-left: 4px solid var(--ledger-blue);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
#chart-wall {
|
| 288 |
+
border: 1px solid var(--ledger-line);
|
| 289 |
+
background:
|
| 290 |
+
linear-gradient(180deg, rgba(16, 21, 29, 0.92), rgba(8, 12, 18, 0.88));
|
| 291 |
+
border-radius: 8px;
|
| 292 |
+
padding: 12px;
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
#pulse-core #dashboard-panel {
|
| 296 |
+
margin-bottom: 10px;
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
#pulse-core #chart-director {
|
| 300 |
+
margin-bottom: 10px;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
#pulse-core #timeline-panel {
|
| 304 |
+
margin-top: 10px;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
#chart-wall .block,
|
| 308 |
+
#signal-row .block {
|
| 309 |
+
background: rgba(8, 12, 18, 0.34) !important;
|
| 310 |
+
border-color: rgba(157, 177, 154, 0.18) !important;
|
| 311 |
+
border-radius: 8px !important;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
#signal-row {
|
| 315 |
+
margin-top: 10px;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
#ask-chat-panel {
|
| 319 |
+
border-left: 4px solid var(--ledger-green);
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
#ask-chat-panel .wrap {
|
| 323 |
+
background: transparent !important;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
#ask-chatbot {
|
| 327 |
+
min-height: 340px;
|
| 328 |
+
border: 1px solid rgba(157, 177, 154, 0.18);
|
| 329 |
+
border-radius: 8px;
|
| 330 |
+
background: rgba(6, 10, 15, 0.72);
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
#ask-chatbot .message {
|
| 334 |
+
border-radius: 8px !important;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
#ask-chatbot .user {
|
| 338 |
+
background: rgba(138, 180, 255, 0.14) !important;
|
| 339 |
+
border: 1px solid rgba(138, 180, 255, 0.22) !important;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
#ask-chatbot .bot {
|
| 343 |
+
background: rgba(139, 220, 139, 0.12) !important;
|
| 344 |
+
border: 1px solid rgba(139, 220, 139, 0.22) !important;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
#ask-row {
|
| 348 |
+
align-items: end;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
#command-panel {
|
| 352 |
+
border-left: 4px solid var(--ledger-gold);
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
#command-output {
|
| 356 |
+
min-height: 96px;
|
| 357 |
+
border: 1px solid rgba(157, 177, 154, 0.18);
|
| 358 |
+
border-radius: 8px;
|
| 359 |
+
background: rgba(6, 10, 15, 0.64);
|
| 360 |
+
padding: 12px;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
#chart-compose-row {
|
| 364 |
+
align-items: end;
|
| 365 |
+
margin-bottom: 10px;
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
#action-grid {
|
| 369 |
+
display: grid !important;
|
| 370 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 371 |
+
gap: 12px;
|
| 372 |
+
align-items: start;
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
#archive-grid,
|
| 376 |
+
.people-grid {
|
| 377 |
+
display: grid !important;
|
| 378 |
+
grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
|
| 379 |
+
gap: 12px;
|
| 380 |
+
align-items: start;
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
#rail-stack {
|
| 384 |
+
display: grid;
|
| 385 |
+
gap: 10px;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
#workbench-tabs {
|
| 389 |
+
margin-top: 12px;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
#workbench-tabs .tab-nav {
|
| 393 |
+
background: rgba(8, 12, 18, 0.72) !important;
|
| 394 |
+
border: 1px solid rgba(157, 177, 154, 0.18) !important;
|
| 395 |
+
border-radius: 8px !important;
|
| 396 |
+
padding: 4px !important;
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
#workbench-tabs button {
|
| 400 |
+
border-radius: 6px !important;
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
.followup-card {
|
| 404 |
+
background: rgba(8, 12, 18, 0.88);
|
| 405 |
+
border: 1px solid var(--ledger-line);
|
| 406 |
+
border-left: 4px solid var(--ledger-gold);
|
| 407 |
+
border-radius: 8px;
|
| 408 |
+
margin: 10px 0;
|
| 409 |
+
padding: 12px;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
.review-card {
|
| 413 |
+
background: rgba(8, 12, 18, 0.88);
|
| 414 |
+
border: 1px solid var(--ledger-line);
|
| 415 |
+
border-left: 4px solid var(--ledger-red);
|
| 416 |
+
border-radius: 8px;
|
| 417 |
+
margin: 10px 0;
|
| 418 |
+
padding: 12px;
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
.review-card code {
|
| 422 |
+
display: block;
|
| 423 |
+
white-space: normal;
|
| 424 |
+
margin-top: 8px;
|
| 425 |
+
color: var(--ledger-ink);
|
| 426 |
+
background: rgba(255, 122, 104, 0.08);
|
| 427 |
+
border: 1px solid rgba(255, 122, 104, 0.22);
|
| 428 |
+
border-radius: 6px;
|
| 429 |
+
padding: 8px;
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
.timeline-rail {
|
| 433 |
+
border-left: 1px solid var(--ledger-line);
|
| 434 |
+
margin-left: 10px;
|
| 435 |
+
padding-left: 14px;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.timeline-card {
|
| 439 |
+
background: rgba(8, 12, 18, 0.88);
|
| 440 |
+
border: 1px solid var(--ledger-line);
|
| 441 |
+
border-left: 4px solid var(--ledger-blue);
|
| 442 |
+
border-radius: 8px;
|
| 443 |
+
margin: 10px 0;
|
| 444 |
+
padding: 12px;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
.timeline-card.income {
|
| 448 |
+
border-left-color: var(--ledger-green);
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
.timeline-card.expense {
|
| 452 |
+
border-left-color: var(--ledger-red);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.timeline-card.due {
|
| 456 |
+
border-left-color: var(--ledger-gold);
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
.timeline-card code {
|
| 460 |
+
color: var(--ledger-muted);
|
| 461 |
+
background: rgba(157, 177, 154, 0.08);
|
| 462 |
+
border-radius: 6px;
|
| 463 |
+
padding: 5px 7px;
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.memory-grid {
|
| 467 |
+
display: grid;
|
| 468 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 469 |
+
gap: 10px;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
.memory-card {
|
| 473 |
+
background: rgba(8, 12, 18, 0.88);
|
| 474 |
+
border: 1px solid var(--ledger-line);
|
| 475 |
+
border-left: 4px solid var(--ledger-green);
|
| 476 |
+
border-radius: 8px;
|
| 477 |
+
padding: 12px;
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
.memory-card.watch {
|
| 481 |
+
border-left-color: var(--ledger-gold);
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
.memory-card.risk {
|
| 485 |
+
border-left-color: var(--ledger-red);
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
.memory-card strong,
|
| 489 |
+
.memory-card span {
|
| 490 |
+
display: block;
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
.memory-card span {
|
| 494 |
+
color: var(--ledger-gold) !important;
|
| 495 |
+
font-size: 12px;
|
| 496 |
+
margin-top: 4px;
|
| 497 |
+
text-transform: uppercase;
|
| 498 |
+
}
|
| 499 |
+
|
| 500 |
+
.memory-card code {
|
| 501 |
+
display: block;
|
| 502 |
+
white-space: normal;
|
| 503 |
+
margin-top: 8px;
|
| 504 |
+
color: var(--ledger-green);
|
| 505 |
+
background: rgba(139, 220, 139, 0.08);
|
| 506 |
+
border: 1px solid rgba(139, 220, 139, 0.22);
|
| 507 |
+
border-radius: 6px;
|
| 508 |
+
padding: 8px;
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
.lantern-grid {
|
| 512 |
+
display: grid;
|
| 513 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 514 |
+
gap: 10px;
|
| 515 |
+
}
|
| 516 |
+
|
| 517 |
+
.lantern-card {
|
| 518 |
+
background: rgba(8, 12, 18, 0.88);
|
| 519 |
+
border: 1px solid var(--ledger-line);
|
| 520 |
+
border-left: 4px solid var(--ledger-blue);
|
| 521 |
+
border-radius: 8px;
|
| 522 |
+
padding: 12px;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
.lantern-card.collect,
|
| 526 |
+
.lantern-card.watch {
|
| 527 |
+
border-left-color: var(--ledger-red);
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
.lantern-card.review {
|
| 531 |
+
border-left-color: var(--ledger-gold);
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
.lantern-card code {
|
| 535 |
+
display: block;
|
| 536 |
+
white-space: normal;
|
| 537 |
+
color: var(--ledger-ink);
|
| 538 |
+
background: rgba(255, 122, 104, 0.08);
|
| 539 |
+
border: 1px solid rgba(255, 122, 104, 0.2);
|
| 540 |
+
border-radius: 6px;
|
| 541 |
+
margin-top: 8px;
|
| 542 |
+
padding: 8px;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
.followup-card code {
|
| 546 |
+
display: block;
|
| 547 |
+
white-space: normal;
|
| 548 |
+
color: var(--ledger-green);
|
| 549 |
+
background: rgba(139, 220, 139, 0.08);
|
| 550 |
+
border: 1px solid rgba(139, 220, 139, 0.22);
|
| 551 |
+
border-radius: 6px;
|
| 552 |
+
padding: 8px;
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
.reply-grid {
|
| 556 |
+
display: grid;
|
| 557 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 558 |
+
gap: 8px;
|
| 559 |
+
margin-top: 10px;
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
.reply-grid code span {
|
| 563 |
+
display: block;
|
| 564 |
+
color: var(--ledger-gold) !important;
|
| 565 |
+
font-family: inherit;
|
| 566 |
+
font-size: 11px;
|
| 567 |
+
font-weight: 700;
|
| 568 |
+
margin-bottom: 4px;
|
| 569 |
+
text-transform: uppercase;
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
#dashboard-panel,
|
| 573 |
+
#automation-panel,
|
| 574 |
+
#review-panel,
|
| 575 |
+
#daily-brief-panel,
|
| 576 |
+
#ask-ledger-panel,
|
| 577 |
+
#command-panel,
|
| 578 |
+
#timeline-panel,
|
| 579 |
+
#memory-panel,
|
| 580 |
+
#lantern-panel,
|
| 581 |
+
#closing-panel,
|
| 582 |
+
#insight-panel {
|
| 583 |
+
border: 1px solid var(--ledger-line);
|
| 584 |
+
background: rgba(16, 21, 29, 0.86);
|
| 585 |
+
border-radius: 8px;
|
| 586 |
+
padding: 14px;
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
@media (max-width: 760px) {
|
| 590 |
+
#cockpit-shell,
|
| 591 |
+
#action-grid,
|
| 592 |
+
#archive-grid,
|
| 593 |
+
.people-grid {
|
| 594 |
+
grid-template-columns: 1fr !important;
|
| 595 |
+
}
|
| 596 |
+
|
| 597 |
+
#input-dock,
|
| 598 |
+
#assistant-rail {
|
| 599 |
+
position: static;
|
| 600 |
+
}
|
| 601 |
+
|
| 602 |
+
.metric-grid {
|
| 603 |
+
grid-template-columns: 1fr;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.dashboard-grid {
|
| 607 |
+
grid-template-columns: 1fr;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
.reply-grid {
|
| 611 |
+
grid-template-columns: 1fr;
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
.memory-grid {
|
| 615 |
+
grid-template-columns: 1fr;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
.lantern-grid {
|
| 619 |
+
grid-template-columns: 1fr;
|
| 620 |
+
}
|
| 621 |
+
}
|
| 622 |
+
"""
|
| 623 |
+
|
| 624 |
+
|
| 625 |
+
def build_demo(
|
| 626 |
+
process_fn: ProcessFn | None = None,
|
| 627 |
+
daily_brief_fn: DailyBriefFn | None = None,
|
| 628 |
+
ask_ledger_fn: AskLedgerFn | None = None,
|
| 629 |
+
chart_composer_fn: ChartComposerFn | None = None,
|
| 630 |
+
) -> gr.Blocks:
|
| 631 |
+
processor = LedgerProcessor.from_env()
|
| 632 |
+
|
| 633 |
+
def local_process(note: str, currency: str, image_urls: list[str] | None = None) -> dict[str, Any]:
|
| 634 |
+
return processor.process(note, currency=currency, image_urls=image_urls).model_dump(mode="json")
|
| 635 |
+
|
| 636 |
+
active_process = process_fn or local_process
|
| 637 |
+
|
| 638 |
+
def local_daily_brief(rows: list[dict[str, Any]], currency: str) -> dict[str, str]:
|
| 639 |
+
return processor.daily_brief(rows, currency=currency)
|
| 640 |
+
|
| 641 |
+
active_daily_brief = daily_brief_fn or local_daily_brief
|
| 642 |
+
|
| 643 |
+
def local_ask_ledger(rows: list[dict[str, Any]], question: str, currency: str) -> dict[str, str]:
|
| 644 |
+
return processor.ask_ledger(rows, question, currency=currency)
|
| 645 |
+
|
| 646 |
+
active_ask_ledger = ask_ledger_fn or local_ask_ledger
|
| 647 |
+
|
| 648 |
+
def local_chart_composer(rows: list[dict[str, Any]], question: str) -> dict[str, str]:
|
| 649 |
+
return chart_spec_from_question(rows, question)
|
| 650 |
+
|
| 651 |
+
active_chart_composer = chart_composer_fn or local_chart_composer
|
| 652 |
+
|
| 653 |
+
with gr.Blocks(
|
| 654 |
+
css=CSS,
|
| 655 |
+
title="Small Shop Ledger",
|
| 656 |
+
theme=gr.themes.Soft(primary_hue="green", secondary_hue="amber", neutral_hue="slate"),
|
| 657 |
+
) as demo:
|
| 658 |
+
ledger_state = gr.State([])
|
| 659 |
+
|
| 660 |
+
gr.Markdown(
|
| 661 |
+
"""
|
| 662 |
+
# Small Shop Ledger
|
| 663 |
+
Messy notes in. Clear books by closing time.
|
| 664 |
+
""",
|
| 665 |
+
elem_id="hero",
|
| 666 |
+
)
|
| 667 |
+
|
| 668 |
+
with gr.Row(elem_id="status-strip"):
|
| 669 |
+
model_badge = gr.Markdown("Model: not run yet")
|
| 670 |
+
row_count = gr.Markdown("Rows: 0")
|
| 671 |
+
|
| 672 |
+
with gr.Row(elem_id="cockpit-shell"):
|
| 673 |
+
with gr.Column(scale=3, elem_id="input-dock", elem_classes=["cockpit-pane"]):
|
| 674 |
+
gr.Markdown("### Capture")
|
| 675 |
+
note_box = gr.Textbox(
|
| 676 |
+
label="Written note",
|
| 677 |
+
placeholder="paid Ravi 1200 for rice bags, customer Nimal owes 750 for tea packets",
|
| 678 |
+
lines=6,
|
| 679 |
+
)
|
| 680 |
+
audio_box = gr.Audio(label="Voice note", sources=["microphone", "upload"], type="filepath")
|
| 681 |
+
document_box = gr.File(
|
| 682 |
+
label="Receipt, bill, or note image",
|
| 683 |
+
file_types=[".pdf", ".png", ".jpg", ".jpeg", ".webp", ".tif", ".tiff", ".bmp", ".txt", ".csv"],
|
| 684 |
+
type="filepath",
|
| 685 |
+
)
|
| 686 |
+
input_choice = gr.Radio(
|
| 687 |
+
label="Input to analyze",
|
| 688 |
+
choices=["Auto", "Text note", "Voice note", "Document"],
|
| 689 |
+
value="Auto",
|
| 690 |
+
interactive=True,
|
| 691 |
+
)
|
| 692 |
+
input_notice = gr.Markdown("Ready for one note.", elem_id="input-notice")
|
| 693 |
+
with gr.Row():
|
| 694 |
+
currency = gr.Dropdown(
|
| 695 |
+
label="Currency",
|
| 696 |
+
choices=["LKR", "USD", "INR", "GBP", "EUR"],
|
| 697 |
+
value="LKR",
|
| 698 |
+
allow_custom_value=True,
|
| 699 |
+
)
|
| 700 |
+
add_button = gr.Button("Add to ledger", variant="primary")
|
| 701 |
+
clear_button = gr.Button("Clear")
|
| 702 |
+
gr.Examples(
|
| 703 |
+
examples=EXAMPLES,
|
| 704 |
+
inputs=note_box,
|
| 705 |
+
label="Try a messy shop note",
|
| 706 |
+
)
|
| 707 |
+
|
| 708 |
+
with gr.Column(scale=6, elem_id="pulse-core", elem_classes=["cockpit-pane"]):
|
| 709 |
+
gr.Markdown("### Shop Pulse")
|
| 710 |
+
dashboard = gr.Markdown(build_dashboard_markdown([]), elem_id="dashboard-panel")
|
| 711 |
+
with gr.Column(elem_id="chart-wall"):
|
| 712 |
+
with gr.Row(elem_id="chart-compose-row"):
|
| 713 |
+
chart_question = gr.Textbox(
|
| 714 |
+
label="Compose chart",
|
| 715 |
+
placeholder="Show me why cash feels low today",
|
| 716 |
+
lines=1,
|
| 717 |
+
scale=5,
|
| 718 |
+
)
|
| 719 |
+
chart_compose_button = gr.Button("Compose", variant="secondary", scale=1)
|
| 720 |
+
chart_director = gr.Markdown(build_chart_markdown([]), elem_id="chart-director")
|
| 721 |
+
primary_chart, secondary_chart, tertiary_chart = build_insight_figures([])
|
| 722 |
+
primary_plot = gr.Plot(value=primary_chart, label="Insight graph")
|
| 723 |
+
with gr.Row(elem_id="signal-row"):
|
| 724 |
+
secondary_plot = gr.Plot(value=secondary_chart, label="Cash trail")
|
| 725 |
+
tertiary_plot = gr.Plot(value=tertiary_chart, label="People ledger")
|
| 726 |
+
timeline = gr.Markdown(build_timeline_markdown([]), elem_id="timeline-panel")
|
| 727 |
+
timeline_plot = gr.Plot(value=timeline_figure([]), label="Shop pulse")
|
| 728 |
+
insights = gr.Markdown(build_insights_markdown([]), elem_id="insight-panel")
|
| 729 |
+
|
| 730 |
+
with gr.Column(scale=4, elem_id="assistant-rail", elem_classes=["cockpit-pane"]):
|
| 731 |
+
gr.Markdown("### Ledger Assistant")
|
| 732 |
+
summary = gr.Markdown("No ledger rows yet.", elem_classes=["summary-card"])
|
| 733 |
+
reminders = gr.Markdown("No reminders yet.", elem_classes=["reminder-card"])
|
| 734 |
+
with gr.Column(elem_id="rail-stack"):
|
| 735 |
+
daily_brief = gr.Markdown(build_daily_brief_markdown([]), elem_id="daily-brief-panel")
|
| 736 |
+
daily_brief_button = gr.Button("Generate daily brief", variant="secondary")
|
| 737 |
+
with gr.Column(elem_id="ask-chat-panel", elem_classes=["chat-panel"]):
|
| 738 |
+
ask_chatbot = gr.Chatbot(
|
| 739 |
+
value=initial_ask_chat(),
|
| 740 |
+
label="Ask My Ledger",
|
| 741 |
+
type="messages",
|
| 742 |
+
height=320,
|
| 743 |
+
elem_id="ask-chatbot",
|
| 744 |
+
)
|
| 745 |
+
with gr.Row(elem_id="ask-row"):
|
| 746 |
+
ask_question = gr.Textbox(
|
| 747 |
+
label="Ask my ledger",
|
| 748 |
+
placeholder="Who owes me most?",
|
| 749 |
+
lines=1,
|
| 750 |
+
scale=5,
|
| 751 |
+
)
|
| 752 |
+
ask_button = gr.Button("Ask", variant="primary", scale=1)
|
| 753 |
+
with gr.Row():
|
| 754 |
+
ask_voice = gr.Audio(
|
| 755 |
+
label="Ask by voice",
|
| 756 |
+
sources=["microphone", "upload"],
|
| 757 |
+
type="filepath",
|
| 758 |
+
scale=3,
|
| 759 |
+
)
|
| 760 |
+
ask_voice_button = gr.Button("Ask voice", variant="secondary", scale=1)
|
| 761 |
+
ask_clear = gr.Button("Reset", scale=1)
|
| 762 |
+
gr.Markdown(
|
| 763 |
+
"""
|
| 764 |
+
### Good questions
|
| 765 |
+
- Who owes me most?
|
| 766 |
+
- What should I follow up today?
|
| 767 |
+
- Where did cash go?
|
| 768 |
+
- Give me the current ledger snapshot.
|
| 769 |
+
""",
|
| 770 |
+
elem_id="ask-ledger-panel",
|
| 771 |
+
)
|
| 772 |
+
with gr.Column(elem_id="command-panel", elem_classes=["chat-panel"]):
|
| 773 |
+
command_choice = gr.Dropdown(
|
| 774 |
+
label="Ledger command",
|
| 775 |
+
choices=COMMAND_ACTIONS,
|
| 776 |
+
value=COMMAND_ACTIONS[0],
|
| 777 |
+
interactive=True,
|
| 778 |
+
)
|
| 779 |
+
command_button = gr.Button("Run command", variant="secondary")
|
| 780 |
+
command_output = gr.Markdown(
|
| 781 |
+
"### Command Palette\nChoose a command to run against the current ledger.",
|
| 782 |
+
elem_id="command-output",
|
| 783 |
+
)
|
| 784 |
+
closing = gr.Markdown(build_closing_ritual_markdown([]), elem_id="closing-panel")
|
| 785 |
+
|
| 786 |
+
with gr.Row(elem_id="action-inbox"):
|
| 787 |
+
with gr.Column():
|
| 788 |
+
gr.Markdown("### Action Inbox")
|
| 789 |
+
with gr.Row(elem_id="action-grid"):
|
| 790 |
+
automation = gr.Markdown(build_reminder_markdown([]), elem_id="automation-panel")
|
| 791 |
+
review = gr.Markdown(build_review_markdown([]), elem_id="review-panel")
|
| 792 |
+
lantern = gr.Markdown(build_anomaly_lantern_markdown([]), elem_id="lantern-panel")
|
| 793 |
+
with gr.Row():
|
| 794 |
+
correction_row = gr.Number(label="Row to correct", value=1, precision=0, scale=1)
|
| 795 |
+
correction_field = gr.Dropdown(
|
| 796 |
+
label="Field",
|
| 797 |
+
choices=CORRECTION_FIELDS,
|
| 798 |
+
value="counterparty",
|
| 799 |
+
interactive=True,
|
| 800 |
+
scale=2,
|
| 801 |
+
)
|
| 802 |
+
correction_value = gr.Textbox(
|
| 803 |
+
label="Correct value",
|
| 804 |
+
placeholder="Type the value that should be stored",
|
| 805 |
+
lines=1,
|
| 806 |
+
scale=4,
|
| 807 |
+
)
|
| 808 |
+
correction_button = gr.Button("Apply correction", variant="secondary", scale=1)
|
| 809 |
+
correction_status = gr.Markdown(
|
| 810 |
+
"Corrections update the ledger, review queue, charts, and CSV export.",
|
| 811 |
+
elem_id="correction-status",
|
| 812 |
+
)
|
| 813 |
+
with gr.Accordion("Operational tables", open=False):
|
| 814 |
+
gr.Markdown("Follow-ups, verification rows, and anomaly signals live here for review before export.")
|
| 815 |
+
with gr.Row():
|
| 816 |
+
automation_table = gr.Dataframe(
|
| 817 |
+
headers=[
|
| 818 |
+
"priority",
|
| 819 |
+
"counterparty",
|
| 820 |
+
"amount",
|
| 821 |
+
"item",
|
| 822 |
+
"next_action",
|
| 823 |
+
"cadence",
|
| 824 |
+
"polite_script",
|
| 825 |
+
"friendly_script",
|
| 826 |
+
"firm_script",
|
| 827 |
+
"source_row",
|
| 828 |
+
],
|
| 829 |
+
datatype=["str", "str", "str", "str", "str", "str", "str", "str", "str", "number"],
|
| 830 |
+
label="Reply studio",
|
| 831 |
+
interactive=False,
|
| 832 |
+
wrap=True,
|
| 833 |
+
)
|
| 834 |
+
with gr.Row():
|
| 835 |
+
review_table = gr.Dataframe(
|
| 836 |
+
headers=["source_row", "issue", "confidence", "counterparty", "item", "amount", "question"],
|
| 837 |
+
datatype=["number", "str", "str", "str", "str", "str", "str"],
|
| 838 |
+
label="Rows to verify",
|
| 839 |
+
interactive=False,
|
| 840 |
+
wrap=True,
|
| 841 |
+
)
|
| 842 |
+
lantern_table = gr.Dataframe(
|
| 843 |
+
headers=["source_row", "severity", "signal", "counterparty", "item", "amount", "reason"],
|
| 844 |
+
datatype=["str", "str", "str", "str", "str", "str", "str"],
|
| 845 |
+
label="Anomaly signals",
|
| 846 |
+
interactive=False,
|
| 847 |
+
wrap=True,
|
| 848 |
+
)
|
| 849 |
+
|
| 850 |
+
with gr.Tabs(elem_id="workbench-tabs"):
|
| 851 |
+
with gr.Tab("People"):
|
| 852 |
+
with gr.Row(elem_id="people-workbench"):
|
| 853 |
+
with gr.Column():
|
| 854 |
+
gr.Markdown("### People Memory")
|
| 855 |
+
memory = gr.Markdown(build_counterparty_memory_markdown([]), elem_id="memory-panel")
|
| 856 |
+
with gr.Column():
|
| 857 |
+
memory_table = gr.Dataframe(
|
| 858 |
+
headers=[
|
| 859 |
+
"party",
|
| 860 |
+
"trust_pulse",
|
| 861 |
+
"total_moved",
|
| 862 |
+
"paid",
|
| 863 |
+
"due",
|
| 864 |
+
"usual_category",
|
| 865 |
+
"usual_item",
|
| 866 |
+
"last_item",
|
| 867 |
+
"row_count",
|
| 868 |
+
"next_message",
|
| 869 |
+
],
|
| 870 |
+
datatype=["str", "str", "str", "str", "str", "str", "str", "str", "number", "str"],
|
| 871 |
+
label="Counterparty memory",
|
| 872 |
+
interactive=False,
|
| 873 |
+
wrap=True,
|
| 874 |
+
)
|
| 875 |
+
party_table = gr.Dataframe(
|
| 876 |
+
headers=["party", "total", "due"],
|
| 877 |
+
datatype=["str", "str", "str"],
|
| 878 |
+
label="People and suppliers",
|
| 879 |
+
interactive=False,
|
| 880 |
+
wrap=True,
|
| 881 |
+
)
|
| 882 |
+
with gr.Tab("Ledger Archive"):
|
| 883 |
+
with gr.Row(elem_id="ledger-archive"):
|
| 884 |
+
with gr.Column(scale=3):
|
| 885 |
+
ledger = gr.Dataframe(
|
| 886 |
+
headers=COLUMNS,
|
| 887 |
+
datatype=["str"] * len(COLUMNS),
|
| 888 |
+
label="Ledger",
|
| 889 |
+
interactive=False,
|
| 890 |
+
wrap=True,
|
| 891 |
+
elem_id="ledger-table",
|
| 892 |
+
)
|
| 893 |
+
download = gr.File(label="Download CSV", elem_id="download-box")
|
| 894 |
+
with gr.Column(scale=2):
|
| 895 |
+
category_table = gr.Dataframe(
|
| 896 |
+
headers=["category", "total", "display"],
|
| 897 |
+
datatype=["str", "number", "str"],
|
| 898 |
+
label="Category heatmap",
|
| 899 |
+
interactive=False,
|
| 900 |
+
wrap=True,
|
| 901 |
+
)
|
| 902 |
+
closing_table = gr.Dataframe(
|
| 903 |
+
headers=["step", "status", "detail"],
|
| 904 |
+
datatype=["str", "str", "str"],
|
| 905 |
+
label="Closing checklist",
|
| 906 |
+
interactive=False,
|
| 907 |
+
wrap=True,
|
| 908 |
+
)
|
| 909 |
+
with gr.Accordion("Timeline event table", open=False):
|
| 910 |
+
timeline_table = gr.Dataframe(
|
| 911 |
+
headers=[
|
| 912 |
+
"source_row",
|
| 913 |
+
"date",
|
| 914 |
+
"badge",
|
| 915 |
+
"direction",
|
| 916 |
+
"counterparty",
|
| 917 |
+
"item",
|
| 918 |
+
"amount",
|
| 919 |
+
"signed_amount",
|
| 920 |
+
"status",
|
| 921 |
+
"story",
|
| 922 |
+
],
|
| 923 |
+
datatype=["number", "str", "str", "str", "str", "str", "str", "number", "str", "str"],
|
| 924 |
+
label="Timeline events",
|
| 925 |
+
interactive=False,
|
| 926 |
+
wrap=True,
|
| 927 |
+
)
|
| 928 |
+
|
| 929 |
+
add_button.click(
|
| 930 |
+
fn=lambda note, audio, document, source_choice, currency_value, state: add_to_ledger(
|
| 931 |
+
note,
|
| 932 |
+
audio,
|
| 933 |
+
document,
|
| 934 |
+
source_choice,
|
| 935 |
+
currency_value,
|
| 936 |
+
state,
|
| 937 |
+
active_process,
|
| 938 |
+
),
|
| 939 |
+
inputs=[note_box, audio_box, document_box, input_choice, currency, ledger_state],
|
| 940 |
+
outputs=[
|
| 941 |
+
ledger,
|
| 942 |
+
summary,
|
| 943 |
+
reminders,
|
| 944 |
+
model_badge,
|
| 945 |
+
row_count,
|
| 946 |
+
download,
|
| 947 |
+
ledger_state,
|
| 948 |
+
note_box,
|
| 949 |
+
audio_box,
|
| 950 |
+
document_box,
|
| 951 |
+
input_choice,
|
| 952 |
+
input_notice,
|
| 953 |
+
dashboard,
|
| 954 |
+
chart_director,
|
| 955 |
+
daily_brief,
|
| 956 |
+
primary_plot,
|
| 957 |
+
secondary_plot,
|
| 958 |
+
tertiary_plot,
|
| 959 |
+
insights,
|
| 960 |
+
automation,
|
| 961 |
+
category_table,
|
| 962 |
+
party_table,
|
| 963 |
+
automation_table,
|
| 964 |
+
review,
|
| 965 |
+
review_table,
|
| 966 |
+
timeline,
|
| 967 |
+
timeline_plot,
|
| 968 |
+
timeline_table,
|
| 969 |
+
memory,
|
| 970 |
+
memory_table,
|
| 971 |
+
lantern,
|
| 972 |
+
lantern_table,
|
| 973 |
+
closing,
|
| 974 |
+
closing_table,
|
| 975 |
+
],
|
| 976 |
+
)
|
| 977 |
+
clear_button.click(
|
| 978 |
+
fn=lambda: (
|
| 979 |
+
*clear_ledger(),
|
| 980 |
+
"### Command Palette\nChoose a command to run against the current ledger.",
|
| 981 |
+
initial_ask_chat(),
|
| 982 |
+
"",
|
| 983 |
+
"Corrections update the ledger, review queue, charts, and CSV export.",
|
| 984 |
+
),
|
| 985 |
+
outputs=[
|
| 986 |
+
ledger,
|
| 987 |
+
summary,
|
| 988 |
+
reminders,
|
| 989 |
+
model_badge,
|
| 990 |
+
row_count,
|
| 991 |
+
download,
|
| 992 |
+
ledger_state,
|
| 993 |
+
note_box,
|
| 994 |
+
audio_box,
|
| 995 |
+
document_box,
|
| 996 |
+
input_choice,
|
| 997 |
+
input_notice,
|
| 998 |
+
dashboard,
|
| 999 |
+
chart_director,
|
| 1000 |
+
daily_brief,
|
| 1001 |
+
primary_plot,
|
| 1002 |
+
secondary_plot,
|
| 1003 |
+
tertiary_plot,
|
| 1004 |
+
insights,
|
| 1005 |
+
automation,
|
| 1006 |
+
category_table,
|
| 1007 |
+
party_table,
|
| 1008 |
+
automation_table,
|
| 1009 |
+
review,
|
| 1010 |
+
review_table,
|
| 1011 |
+
timeline,
|
| 1012 |
+
timeline_plot,
|
| 1013 |
+
timeline_table,
|
| 1014 |
+
memory,
|
| 1015 |
+
memory_table,
|
| 1016 |
+
lantern,
|
| 1017 |
+
lantern_table,
|
| 1018 |
+
closing,
|
| 1019 |
+
closing_table,
|
| 1020 |
+
command_output,
|
| 1021 |
+
ask_chatbot,
|
| 1022 |
+
ask_question,
|
| 1023 |
+
correction_status,
|
| 1024 |
+
],
|
| 1025 |
+
)
|
| 1026 |
+
daily_brief_button.click(
|
| 1027 |
+
fn=lambda state, currency_value: generate_daily_brief(state, currency_value, active_daily_brief),
|
| 1028 |
+
inputs=[ledger_state, currency],
|
| 1029 |
+
outputs=[daily_brief],
|
| 1030 |
+
)
|
| 1031 |
+
ask_button.click(
|
| 1032 |
+
fn=lambda state, question, history, currency_value: ask_ledger_chat(
|
| 1033 |
+
state,
|
| 1034 |
+
question,
|
| 1035 |
+
history,
|
| 1036 |
+
currency_value,
|
| 1037 |
+
active_ask_ledger,
|
| 1038 |
+
),
|
| 1039 |
+
inputs=[ledger_state, ask_question, ask_chatbot, currency],
|
| 1040 |
+
outputs=[ask_chatbot, ask_question],
|
| 1041 |
+
)
|
| 1042 |
+
ask_question.submit(
|
| 1043 |
+
fn=lambda state, question, history, currency_value: ask_ledger_chat(
|
| 1044 |
+
state,
|
| 1045 |
+
question,
|
| 1046 |
+
history,
|
| 1047 |
+
currency_value,
|
| 1048 |
+
active_ask_ledger,
|
| 1049 |
+
),
|
| 1050 |
+
inputs=[ledger_state, ask_question, ask_chatbot, currency],
|
| 1051 |
+
outputs=[ask_chatbot, ask_question],
|
| 1052 |
+
)
|
| 1053 |
+
ask_clear.click(
|
| 1054 |
+
fn=lambda: (initial_ask_chat(), ""),
|
| 1055 |
+
outputs=[ask_chatbot, ask_question],
|
| 1056 |
+
)
|
| 1057 |
+
ask_voice_button.click(
|
| 1058 |
+
fn=lambda state, audio, history, currency_value: ask_ledger_voice_chat(
|
| 1059 |
+
state,
|
| 1060 |
+
audio,
|
| 1061 |
+
history,
|
| 1062 |
+
currency_value,
|
| 1063 |
+
active_ask_ledger,
|
| 1064 |
+
),
|
| 1065 |
+
inputs=[ledger_state, ask_voice, ask_chatbot, currency],
|
| 1066 |
+
outputs=[ask_chatbot, ask_question, ask_voice],
|
| 1067 |
+
)
|
| 1068 |
+
command_button.click(
|
| 1069 |
+
fn=run_command_palette,
|
| 1070 |
+
inputs=[ledger_state, command_choice],
|
| 1071 |
+
outputs=[command_output],
|
| 1072 |
+
)
|
| 1073 |
+
correction_button.click(
|
| 1074 |
+
fn=apply_row_correction,
|
| 1075 |
+
inputs=[ledger_state, correction_row, correction_field, correction_value, currency],
|
| 1076 |
+
outputs=[
|
| 1077 |
+
ledger,
|
| 1078 |
+
summary,
|
| 1079 |
+
reminders,
|
| 1080 |
+
model_badge,
|
| 1081 |
+
row_count,
|
| 1082 |
+
download,
|
| 1083 |
+
ledger_state,
|
| 1084 |
+
input_notice,
|
| 1085 |
+
dashboard,
|
| 1086 |
+
chart_director,
|
| 1087 |
+
daily_brief,
|
| 1088 |
+
primary_plot,
|
| 1089 |
+
secondary_plot,
|
| 1090 |
+
tertiary_plot,
|
| 1091 |
+
insights,
|
| 1092 |
+
automation,
|
| 1093 |
+
category_table,
|
| 1094 |
+
party_table,
|
| 1095 |
+
automation_table,
|
| 1096 |
+
review,
|
| 1097 |
+
review_table,
|
| 1098 |
+
timeline,
|
| 1099 |
+
timeline_plot,
|
| 1100 |
+
timeline_table,
|
| 1101 |
+
memory,
|
| 1102 |
+
memory_table,
|
| 1103 |
+
lantern,
|
| 1104 |
+
lantern_table,
|
| 1105 |
+
closing,
|
| 1106 |
+
closing_table,
|
| 1107 |
+
correction_status,
|
| 1108 |
+
],
|
| 1109 |
+
)
|
| 1110 |
+
chart_compose_button.click(
|
| 1111 |
+
fn=lambda state, question: compose_chart(state, question, active_chart_composer),
|
| 1112 |
+
inputs=[ledger_state, chart_question],
|
| 1113 |
+
outputs=[chart_director, primary_plot, chart_question],
|
| 1114 |
+
)
|
| 1115 |
+
chart_question.submit(
|
| 1116 |
+
fn=lambda state, question: compose_chart(state, question, active_chart_composer),
|
| 1117 |
+
inputs=[ledger_state, chart_question],
|
| 1118 |
+
outputs=[chart_director, primary_plot, chart_question],
|
| 1119 |
+
)
|
| 1120 |
+
|
| 1121 |
+
return demo
|
| 1122 |
+
|
| 1123 |
+
|
| 1124 |
+
def add_to_ledger(
|
| 1125 |
+
note: str,
|
| 1126 |
+
audio_path: str | None,
|
| 1127 |
+
document_path: Any,
|
| 1128 |
+
source_choice: str,
|
| 1129 |
+
currency: str,
|
| 1130 |
+
state: list[dict[str, Any]] | None,
|
| 1131 |
+
process_fn: ProcessFn,
|
| 1132 |
+
) -> tuple[Any, ...]:
|
| 1133 |
+
rows = state or []
|
| 1134 |
+
choice = choose_input(note, audio_path, document_path, source_choice)
|
| 1135 |
+
if choice["status"] != "ready":
|
| 1136 |
+
frame = pd.DataFrame(rows, columns=COLUMNS)
|
| 1137 |
+
return (
|
| 1138 |
+
frame,
|
| 1139 |
+
build_summary(rows, {}),
|
| 1140 |
+
build_reminders(rows, {}),
|
| 1141 |
+
"Model: waiting for input",
|
| 1142 |
+
f"Rows: {len(rows)}",
|
| 1143 |
+
write_csv(rows) if rows else None,
|
| 1144 |
+
rows,
|
| 1145 |
+
gr.update(),
|
| 1146 |
+
gr.update(),
|
| 1147 |
+
gr.update(),
|
| 1148 |
+
gr.update(),
|
| 1149 |
+
choice["notice"],
|
| 1150 |
+
*render_intelligence(rows),
|
| 1151 |
+
)
|
| 1152 |
+
|
| 1153 |
+
if choice["source"] == "audio":
|
| 1154 |
+
combined_note = transcribe_audio(audio_path)
|
| 1155 |
+
image_urls = None
|
| 1156 |
+
if not combined_note:
|
| 1157 |
+
frame = pd.DataFrame(rows, columns=COLUMNS)
|
| 1158 |
+
return (
|
| 1159 |
+
frame,
|
| 1160 |
+
build_summary(rows, {}),
|
| 1161 |
+
build_reminders(rows, {}),
|
| 1162 |
+
"Model: waiting for audio transcript",
|
| 1163 |
+
f"Rows: {len(rows)}",
|
| 1164 |
+
write_csv(rows) if rows else None,
|
| 1165 |
+
rows,
|
| 1166 |
+
gr.update(),
|
| 1167 |
+
gr.update(),
|
| 1168 |
+
gr.update(),
|
| 1169 |
+
gr.update(value="Voice note"),
|
| 1170 |
+
"I could not transcribe that voice note. Try another recording or paste the note.",
|
| 1171 |
+
*render_intelligence(rows),
|
| 1172 |
+
)
|
| 1173 |
+
elif choice["source"] == "document":
|
| 1174 |
+
document = prepare_document_input(document_path)
|
| 1175 |
+
combined_note = build_document_prompt(document)
|
| 1176 |
+
image_urls = document.get("image_urls") or None
|
| 1177 |
+
if not combined_note and not image_urls:
|
| 1178 |
+
frame = pd.DataFrame(rows, columns=COLUMNS)
|
| 1179 |
+
return (
|
| 1180 |
+
frame,
|
| 1181 |
+
build_summary(rows, {}),
|
| 1182 |
+
build_reminders(rows, {}),
|
| 1183 |
+
"Model: waiting for document text",
|
| 1184 |
+
f"Rows: {len(rows)}",
|
| 1185 |
+
write_csv(rows) if rows else None,
|
| 1186 |
+
rows,
|
| 1187 |
+
gr.update(),
|
| 1188 |
+
gr.update(),
|
| 1189 |
+
gr.update(),
|
| 1190 |
+
gr.update(value="Document"),
|
| 1191 |
+
"I could not prepare that document. Try a PDF, receipt image, or pasted note.",
|
| 1192 |
+
*render_intelligence(rows),
|
| 1193 |
+
)
|
| 1194 |
+
else:
|
| 1195 |
+
combined_note = (note or "").strip()
|
| 1196 |
+
image_urls = None
|
| 1197 |
+
|
| 1198 |
+
result = process_fn(combined_note, currency or "LKR", image_urls)
|
| 1199 |
+
rows = rows + compact_rows(result.get("entries", []))
|
| 1200 |
+
|
| 1201 |
+
frame = pd.DataFrame(rows, columns=COLUMNS)
|
| 1202 |
+
summary = build_summary(rows, result)
|
| 1203 |
+
reminder_text = build_reminders(rows, result)
|
| 1204 |
+
csv_path = write_csv(rows) if rows else None
|
| 1205 |
+
model = result.get("model_used", "unknown")
|
| 1206 |
+
notice = f"Added {len(result.get('entries', []))} row(s) from the {choice['label'].lower()}."
|
| 1207 |
+
next_note = gr.update(value="") if choice["source"] == "text" else gr.update()
|
| 1208 |
+
next_audio = gr.update(value=None) if choice["source"] == "audio" else gr.update()
|
| 1209 |
+
next_document = gr.update(value=None) if choice["source"] == "document" else gr.update()
|
| 1210 |
+
|
| 1211 |
+
return (
|
| 1212 |
+
frame,
|
| 1213 |
+
summary,
|
| 1214 |
+
reminder_text,
|
| 1215 |
+
f"Model: `{model}`",
|
| 1216 |
+
f"Rows: {len(rows)}",
|
| 1217 |
+
csv_path,
|
| 1218 |
+
rows,
|
| 1219 |
+
next_note,
|
| 1220 |
+
next_audio,
|
| 1221 |
+
next_document,
|
| 1222 |
+
gr.update(value="Auto"),
|
| 1223 |
+
notice,
|
| 1224 |
+
*render_intelligence(rows),
|
| 1225 |
+
)
|
| 1226 |
+
|
| 1227 |
+
|
| 1228 |
+
def choose_input(note: str | None, audio_path: str | None, document_path: Any, source_choice: str | None) -> dict[str, str]:
|
| 1229 |
+
has_text = bool((note or "").strip())
|
| 1230 |
+
has_audio = bool(audio_path)
|
| 1231 |
+
has_document = bool(document_path)
|
| 1232 |
+
choice = source_choice or "Auto"
|
| 1233 |
+
present = [
|
| 1234 |
+
label
|
| 1235 |
+
for label, exists in (
|
| 1236 |
+
("written note", has_text),
|
| 1237 |
+
("voice note", has_audio),
|
| 1238 |
+
("document", has_document),
|
| 1239 |
+
)
|
| 1240 |
+
if exists
|
| 1241 |
+
]
|
| 1242 |
+
|
| 1243 |
+
if len(present) > 1 and choice == "Auto":
|
| 1244 |
+
return {
|
| 1245 |
+
"status": "conflict",
|
| 1246 |
+
"notice": f"Multiple inputs are present ({', '.join(present)}). Choose Text note, Voice note, or Document, then add it to the ledger.",
|
| 1247 |
+
}
|
| 1248 |
+
if choice == "Text note" and not has_text:
|
| 1249 |
+
return {"status": "missing", "notice": "Text note is selected, but the written note is empty."}
|
| 1250 |
+
if choice == "Voice note" and not has_audio:
|
| 1251 |
+
return {"status": "missing", "notice": "Voice note is selected, but no audio is attached."}
|
| 1252 |
+
if choice == "Document" and not has_document:
|
| 1253 |
+
return {"status": "missing", "notice": "Document is selected, but no file is attached."}
|
| 1254 |
+
if not has_text and not has_audio and not has_document:
|
| 1255 |
+
return {"status": "missing", "notice": "Add a written note, record a voice note, or upload a document first."}
|
| 1256 |
+
if choice == "Voice note" or (choice == "Auto" and has_audio):
|
| 1257 |
+
return {"status": "ready", "source": "audio", "label": "Voice note"}
|
| 1258 |
+
if choice == "Document" or (choice == "Auto" and has_document):
|
| 1259 |
+
return {"status": "ready", "source": "document", "label": "Document"}
|
| 1260 |
+
return {"status": "ready", "source": "text", "label": "Text note"}
|
| 1261 |
+
|
| 1262 |
+
|
| 1263 |
+
def build_document_prompt(document: dict[str, Any]) -> str:
|
| 1264 |
+
kind = document.get("kind") or "document"
|
| 1265 |
+
page_count = document.get("page_count") or 0
|
| 1266 |
+
text = str(document.get("text") or "").strip()
|
| 1267 |
+
parts = [
|
| 1268 |
+
f"Uploaded {kind} with {page_count} page/image(s).",
|
| 1269 |
+
"Extract shop ledger entries from the visible document content.",
|
| 1270 |
+
]
|
| 1271 |
+
if text:
|
| 1272 |
+
parts.append(f"Text extracted from the document:\n{text}")
|
| 1273 |
+
return "\n".join(parts).strip()
|
| 1274 |
+
|
| 1275 |
+
|
| 1276 |
+
def h(value: Any) -> str:
|
| 1277 |
+
return escape(str(value or ""), quote=True)
|
| 1278 |
+
|
| 1279 |
+
|
| 1280 |
+
def compact_rows(entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 1281 |
+
rows: list[dict[str, Any]] = []
|
| 1282 |
+
for entry in entries:
|
| 1283 |
+
row = {column: entry.get(column, "") for column in COLUMNS}
|
| 1284 |
+
row["amount"] = float(row["amount"] or 0)
|
| 1285 |
+
row["confidence"] = round(float(row["confidence"] or 0), 2)
|
| 1286 |
+
rows.append(row)
|
| 1287 |
+
return rows
|
| 1288 |
+
|
| 1289 |
+
|
| 1290 |
+
def apply_row_correction(
|
| 1291 |
+
state: list[dict[str, Any]] | None,
|
| 1292 |
+
row_number: float | int | None,
|
| 1293 |
+
field: str,
|
| 1294 |
+
value: str,
|
| 1295 |
+
currency: str,
|
| 1296 |
+
) -> tuple[Any, ...]:
|
| 1297 |
+
rows = [dict(row) for row in (state or [])]
|
| 1298 |
+
if not rows:
|
| 1299 |
+
return render_local_edit(rows, currency, "Add ledger rows before applying a correction.")
|
| 1300 |
+
|
| 1301 |
+
try:
|
| 1302 |
+
index = int(row_number or 0) - 1
|
| 1303 |
+
except (TypeError, ValueError):
|
| 1304 |
+
return render_local_edit(rows, currency, "Choose a valid row number.")
|
| 1305 |
+
|
| 1306 |
+
if index < 0 or index >= len(rows):
|
| 1307 |
+
return render_local_edit(rows, currency, f"Row {row_number} is not in the current ledger.")
|
| 1308 |
+
|
| 1309 |
+
if field not in CORRECTION_FIELDS:
|
| 1310 |
+
return render_local_edit(rows, currency, "Choose a field that can be corrected.")
|
| 1311 |
+
|
| 1312 |
+
raw_value = (value or "").strip()
|
| 1313 |
+
if not raw_value:
|
| 1314 |
+
return render_local_edit(rows, currency, "Type the corrected value first.")
|
| 1315 |
+
|
| 1316 |
+
try:
|
| 1317 |
+
rows[index][field] = normalize_correction_value(field, raw_value)
|
| 1318 |
+
except ValueError as exc:
|
| 1319 |
+
return render_local_edit(rows, currency, str(exc))
|
| 1320 |
+
|
| 1321 |
+
if field != "confidence":
|
| 1322 |
+
rows[index]["confidence"] = max(float(rows[index].get("confidence") or 0), 0.9)
|
| 1323 |
+
if not rows[index].get("currency"):
|
| 1324 |
+
rows[index]["currency"] = currency or "LKR"
|
| 1325 |
+
|
| 1326 |
+
return render_local_edit(rows, currency, f"Updated row {index + 1}: {field} = {h(raw_value)}.")
|
| 1327 |
+
|
| 1328 |
+
|
| 1329 |
+
def normalize_correction_value(field: str, value: str) -> Any:
|
| 1330 |
+
if field == "amount":
|
| 1331 |
+
cleaned = value.replace(",", "")
|
| 1332 |
+
try:
|
| 1333 |
+
return float(cleaned)
|
| 1334 |
+
except ValueError as exc:
|
| 1335 |
+
raise ValueError("Amount corrections need a number, like 1200 or 1,200.") from exc
|
| 1336 |
+
if field == "confidence":
|
| 1337 |
+
try:
|
| 1338 |
+
number = float(value)
|
| 1339 |
+
except ValueError as exc:
|
| 1340 |
+
raise ValueError("Confidence corrections need a number from 0 to 1.") from exc
|
| 1341 |
+
return round(max(0.0, min(1.0, number)), 2)
|
| 1342 |
+
if field == "payment_status":
|
| 1343 |
+
normalized = value.lower()
|
| 1344 |
+
if normalized not in {"paid", "due", "partial", "unknown"}:
|
| 1345 |
+
raise ValueError("Payment status must be paid, due, partial, or unknown.")
|
| 1346 |
+
return normalized
|
| 1347 |
+
if field == "direction":
|
| 1348 |
+
normalized = value.lower()
|
| 1349 |
+
if normalized not in {"expense", "income", "transfer", "unknown"}:
|
| 1350 |
+
raise ValueError("Direction must be expense, income, transfer, or unknown.")
|
| 1351 |
+
return normalized
|
| 1352 |
+
return value
|
| 1353 |
+
|
| 1354 |
+
|
| 1355 |
+
def render_local_edit(rows: list[dict[str, Any]], currency: str, message: str) -> tuple[Any, ...]:
|
| 1356 |
+
frame = pd.DataFrame(rows, columns=COLUMNS)
|
| 1357 |
+
return (
|
| 1358 |
+
frame,
|
| 1359 |
+
build_summary(rows, {}),
|
| 1360 |
+
build_reminders(rows, {}),
|
| 1361 |
+
"Model: local correction",
|
| 1362 |
+
f"Rows: {len(rows)}",
|
| 1363 |
+
write_csv(rows) if rows else None,
|
| 1364 |
+
rows,
|
| 1365 |
+
message,
|
| 1366 |
+
*render_intelligence(rows),
|
| 1367 |
+
message,
|
| 1368 |
+
)
|
| 1369 |
+
|
| 1370 |
+
|
| 1371 |
+
def build_summary(rows: list[dict[str, Any]], result: dict[str, Any]) -> str:
|
| 1372 |
+
if not rows:
|
| 1373 |
+
return "No ledger rows yet."
|
| 1374 |
+
|
| 1375 |
+
expenses = sum_amount(rows, "expense", "paid")
|
| 1376 |
+
income = sum_amount(rows, "income", "paid")
|
| 1377 |
+
due = sum(float(row.get("amount") or 0) for row in rows if row.get("payment_status") == "due")
|
| 1378 |
+
categories = category_totals(rows)
|
| 1379 |
+
category_text = ", ".join(f"{h(name)}: {amount:,.0f}" for name, amount in categories[:4])
|
| 1380 |
+
questions = result.get("questions") or []
|
| 1381 |
+
question_text = "\n".join(f"- {h(question)}" for question in questions)
|
| 1382 |
+
|
| 1383 |
+
summary = (
|
| 1384 |
+
f"### Totals\n"
|
| 1385 |
+
f"- Expenses paid: **{expenses:,.0f}**\n"
|
| 1386 |
+
f"- Income received: **{income:,.0f}**\n"
|
| 1387 |
+
f"- Still due: **{due:,.0f}**\n"
|
| 1388 |
+
)
|
| 1389 |
+
if category_text:
|
| 1390 |
+
summary += f"- Top categories: {category_text}\n"
|
| 1391 |
+
if question_text:
|
| 1392 |
+
summary += f"\n### Check With User\n{question_text}\n"
|
| 1393 |
+
return summary
|
| 1394 |
+
|
| 1395 |
+
|
| 1396 |
+
def build_reminders(rows: list[dict[str, Any]], result: dict[str, Any]) -> str:
|
| 1397 |
+
reminders = list(result.get("reminders") or [])
|
| 1398 |
+
reminders.extend(row["reminder"] for row in rows if row.get("reminder"))
|
| 1399 |
+
unique = []
|
| 1400 |
+
for reminder in reminders:
|
| 1401 |
+
if reminder and reminder not in unique:
|
| 1402 |
+
unique.append(reminder)
|
| 1403 |
+
if not unique:
|
| 1404 |
+
return "No reminders yet."
|
| 1405 |
+
return "### Follow-ups\n" + "\n".join(f"- {h(reminder)}" for reminder in unique[:8])
|
| 1406 |
+
|
| 1407 |
+
|
| 1408 |
+
def sum_amount(rows: list[dict[str, Any]], direction: str, status: str) -> float:
|
| 1409 |
+
return sum(
|
| 1410 |
+
float(row.get("amount") or 0)
|
| 1411 |
+
for row in rows
|
| 1412 |
+
if row.get("direction") == direction and row.get("payment_status") == status
|
| 1413 |
+
)
|
| 1414 |
+
|
| 1415 |
+
|
| 1416 |
+
def category_totals(rows: list[dict[str, Any]]) -> list[tuple[str, float]]:
|
| 1417 |
+
totals: dict[str, float] = {}
|
| 1418 |
+
for row in rows:
|
| 1419 |
+
category = row.get("category") or "uncategorized"
|
| 1420 |
+
totals[category] = totals.get(category, 0.0) + float(row.get("amount") or 0)
|
| 1421 |
+
return sorted(totals.items(), key=lambda item: item[1], reverse=True)
|
| 1422 |
+
|
| 1423 |
+
|
| 1424 |
+
def write_csv(rows: list[dict[str, Any]]) -> str:
|
| 1425 |
+
handle = tempfile.NamedTemporaryFile("w", suffix=".csv", delete=False, newline="")
|
| 1426 |
+
with handle:
|
| 1427 |
+
writer = csv.DictWriter(handle, fieldnames=COLUMNS)
|
| 1428 |
+
writer.writeheader()
|
| 1429 |
+
writer.writerows(rows)
|
| 1430 |
+
return handle.name
|
| 1431 |
+
|
| 1432 |
+
|
| 1433 |
+
def render_intelligence(rows: list[dict[str, Any]]) -> tuple[Any, ...]:
|
| 1434 |
+
categories, parties, followups, reviews = build_tables(rows)
|
| 1435 |
+
primary_chart, secondary_chart, tertiary_chart = build_insight_figures(rows)
|
| 1436 |
+
return (
|
| 1437 |
+
build_dashboard_markdown(rows),
|
| 1438 |
+
build_chart_markdown(rows),
|
| 1439 |
+
build_daily_brief_markdown(rows),
|
| 1440 |
+
primary_chart,
|
| 1441 |
+
secondary_chart,
|
| 1442 |
+
tertiary_chart,
|
| 1443 |
+
build_insights_markdown(rows),
|
| 1444 |
+
build_reminder_markdown(rows),
|
| 1445 |
+
pd.DataFrame(categories, columns=["category", "total", "display"]),
|
| 1446 |
+
pd.DataFrame(parties, columns=["party", "total", "due"]),
|
| 1447 |
+
pd.DataFrame(
|
| 1448 |
+
followups,
|
| 1449 |
+
columns=[
|
| 1450 |
+
"priority",
|
| 1451 |
+
"counterparty",
|
| 1452 |
+
"amount",
|
| 1453 |
+
"item",
|
| 1454 |
+
"next_action",
|
| 1455 |
+
"cadence",
|
| 1456 |
+
"polite_script",
|
| 1457 |
+
"friendly_script",
|
| 1458 |
+
"firm_script",
|
| 1459 |
+
"source_row",
|
| 1460 |
+
],
|
| 1461 |
+
),
|
| 1462 |
+
build_review_markdown(rows),
|
| 1463 |
+
pd.DataFrame(
|
| 1464 |
+
reviews,
|
| 1465 |
+
columns=["source_row", "issue", "confidence", "counterparty", "item", "amount", "question"],
|
| 1466 |
+
),
|
| 1467 |
+
build_timeline_markdown(rows),
|
| 1468 |
+
timeline_figure(rows),
|
| 1469 |
+
pd.DataFrame(
|
| 1470 |
+
timeline_rows(rows),
|
| 1471 |
+
columns=[
|
| 1472 |
+
"source_row",
|
| 1473 |
+
"date",
|
| 1474 |
+
"badge",
|
| 1475 |
+
"direction",
|
| 1476 |
+
"counterparty",
|
| 1477 |
+
"item",
|
| 1478 |
+
"amount",
|
| 1479 |
+
"signed_amount",
|
| 1480 |
+
"status",
|
| 1481 |
+
"story",
|
| 1482 |
+
],
|
| 1483 |
+
),
|
| 1484 |
+
build_counterparty_memory_markdown(rows),
|
| 1485 |
+
pd.DataFrame(
|
| 1486 |
+
counterparty_memory_cards(rows),
|
| 1487 |
+
columns=[
|
| 1488 |
+
"party",
|
| 1489 |
+
"trust_pulse",
|
| 1490 |
+
"total_moved",
|
| 1491 |
+
"paid",
|
| 1492 |
+
"due",
|
| 1493 |
+
"usual_category",
|
| 1494 |
+
"usual_item",
|
| 1495 |
+
"last_item",
|
| 1496 |
+
"row_count",
|
| 1497 |
+
"next_message",
|
| 1498 |
+
],
|
| 1499 |
+
),
|
| 1500 |
+
build_anomaly_lantern_markdown(rows),
|
| 1501 |
+
pd.DataFrame(
|
| 1502 |
+
anomaly_lantern_rows(rows),
|
| 1503 |
+
columns=["source_row", "severity", "signal", "counterparty", "item", "amount", "reason"],
|
| 1504 |
+
),
|
| 1505 |
+
build_closing_ritual_markdown(rows),
|
| 1506 |
+
pd.DataFrame(closing_checklist(rows), columns=["step", "status", "detail"]),
|
| 1507 |
+
)
|
| 1508 |
+
|
| 1509 |
+
|
| 1510 |
+
def clear_ledger() -> tuple[Any, ...]:
|
| 1511 |
+
return (
|
| 1512 |
+
pd.DataFrame([], columns=COLUMNS),
|
| 1513 |
+
"No ledger rows yet.",
|
| 1514 |
+
"No reminders yet.",
|
| 1515 |
+
"Model: not run yet",
|
| 1516 |
+
"Rows: 0",
|
| 1517 |
+
None,
|
| 1518 |
+
[],
|
| 1519 |
+
"",
|
| 1520 |
+
None,
|
| 1521 |
+
None,
|
| 1522 |
+
"Auto",
|
| 1523 |
+
"Ready for one note.",
|
| 1524 |
+
*render_intelligence([]),
|
| 1525 |
+
)
|
| 1526 |
+
|
| 1527 |
+
|
| 1528 |
+
def generate_daily_brief(
|
| 1529 |
+
state: list[dict[str, Any]] | None,
|
| 1530 |
+
currency: str,
|
| 1531 |
+
daily_brief_fn: DailyBriefFn,
|
| 1532 |
+
) -> str:
|
| 1533 |
+
rows = state or []
|
| 1534 |
+
result = daily_brief_fn(rows, currency or "LKR")
|
| 1535 |
+
return build_daily_brief_markdown(rows, result.get("brief"), result.get("model_used", "unknown"))
|
| 1536 |
+
|
| 1537 |
+
|
| 1538 |
+
def ask_ledger(
|
| 1539 |
+
state: list[dict[str, Any]] | None,
|
| 1540 |
+
question: str,
|
| 1541 |
+
currency: str,
|
| 1542 |
+
ask_ledger_fn: AskLedgerFn,
|
| 1543 |
+
) -> str:
|
| 1544 |
+
rows = state or []
|
| 1545 |
+
result = ask_ledger_fn(rows, question or "", currency or "LKR")
|
| 1546 |
+
answer = result.get("answer") or "No answer available."
|
| 1547 |
+
model_used = result.get("model_used", "unknown")
|
| 1548 |
+
return f"### Ask My Ledger\n{answer}\n\n<small>Answer: {model_used}</small>"
|
| 1549 |
+
|
| 1550 |
+
|
| 1551 |
+
def initial_ask_chat() -> ChatHistory:
|
| 1552 |
+
return [
|
| 1553 |
+
{
|
| 1554 |
+
"role": "assistant",
|
| 1555 |
+
"content": "Ask me about dues, follow-ups, spending, or today’s cash position after you add ledger rows.",
|
| 1556 |
+
}
|
| 1557 |
+
]
|
| 1558 |
+
|
| 1559 |
+
|
| 1560 |
+
def ask_ledger_chat(
|
| 1561 |
+
state: list[dict[str, Any]] | None,
|
| 1562 |
+
question: str,
|
| 1563 |
+
history: ChatHistory | None,
|
| 1564 |
+
currency: str,
|
| 1565 |
+
ask_ledger_fn: AskLedgerFn,
|
| 1566 |
+
) -> tuple[ChatHistory, str]:
|
| 1567 |
+
clean_question = (question or "").strip()
|
| 1568 |
+
next_history: ChatHistory = list(history or initial_ask_chat())
|
| 1569 |
+
if not clean_question:
|
| 1570 |
+
next_history.append({"role": "assistant", "content": "Ask a question first, then I’ll answer from the ledger rows."})
|
| 1571 |
+
return next_history, ""
|
| 1572 |
+
|
| 1573 |
+
rows = state or []
|
| 1574 |
+
result = ask_ledger_fn(rows, clean_question, currency or "LKR")
|
| 1575 |
+
answer = result.get("answer") or "No answer available."
|
| 1576 |
+
model_used = result.get("model_used", "unknown")
|
| 1577 |
+
next_history.append({"role": "user", "content": clean_question})
|
| 1578 |
+
next_history.append({"role": "assistant", "content": f"{answer}\n\nAnswer source: {model_used}"})
|
| 1579 |
+
return next_history, ""
|
| 1580 |
+
|
| 1581 |
+
|
| 1582 |
+
def ask_ledger_voice_chat(
|
| 1583 |
+
state: list[dict[str, Any]] | None,
|
| 1584 |
+
audio_path: str | None,
|
| 1585 |
+
history: ChatHistory | None,
|
| 1586 |
+
currency: str,
|
| 1587 |
+
ask_ledger_fn: AskLedgerFn,
|
| 1588 |
+
transcribe_fn: Callable[[str | None], str] = transcribe_audio,
|
| 1589 |
+
) -> tuple[ChatHistory, str, Any]:
|
| 1590 |
+
transcript = transcribe_fn(audio_path).strip()
|
| 1591 |
+
next_history: ChatHistory = list(history or initial_ask_chat())
|
| 1592 |
+
if not transcript:
|
| 1593 |
+
next_history.append(
|
| 1594 |
+
{
|
| 1595 |
+
"role": "assistant",
|
| 1596 |
+
"content": "I could not hear a question clearly. Try recording again or type the question.",
|
| 1597 |
+
}
|
| 1598 |
+
)
|
| 1599 |
+
return next_history, "", None
|
| 1600 |
+
history_with_answer, _ = ask_ledger_chat(state, transcript, next_history, currency, ask_ledger_fn)
|
| 1601 |
+
return history_with_answer, "", None
|
| 1602 |
+
|
| 1603 |
+
|
| 1604 |
+
def run_command_palette(state: list[dict[str, Any]] | None, command: str) -> str:
|
| 1605 |
+
return run_ledger_command(state or [], command)
|
| 1606 |
+
|
| 1607 |
+
|
| 1608 |
+
def compose_chart(
|
| 1609 |
+
state: list[dict[str, Any]] | None,
|
| 1610 |
+
question: str,
|
| 1611 |
+
chart_composer_fn: ChartComposerFn,
|
| 1612 |
+
) -> tuple[str, Any, str]:
|
| 1613 |
+
rows = state or []
|
| 1614 |
+
spec = chart_composer_fn(rows, question or "")
|
| 1615 |
+
return build_chart_composer_markdown(question or "", spec), figure_for_chart_id(rows, spec.get("chart", "")), ""
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
tests/test_heuristics.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
from shop_ledger.heuristics import heuristic_extract
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class HeuristicExtractionTests(unittest.TestCase):
|
| 7 |
+
def test_extracts_multiple_rows_and_due_reminder(self):
|
| 8 |
+
result = heuristic_extract(
|
| 9 |
+
"paid Ravi 1200 for rice bags, customer Nimal owes 750 for tea packets"
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
self.assertEqual(len(result.entries), 2)
|
| 13 |
+
self.assertEqual(result.entries[0].amount, 1200)
|
| 14 |
+
self.assertEqual(result.entries[0].direction, "expense")
|
| 15 |
+
self.assertEqual(result.entries[1].amount, 750)
|
| 16 |
+
self.assertEqual(result.entries[1].payment_status, "due")
|
| 17 |
+
self.assertTrue(result.reminders)
|
| 18 |
+
|
| 19 |
+
def test_missing_amount_adds_question(self):
|
| 20 |
+
result = heuristic_extract("paid Ravi for rice bags")
|
| 21 |
+
|
| 22 |
+
self.assertEqual(result.entries[0].amount, 0)
|
| 23 |
+
self.assertTrue(result.questions)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
if __name__ == "__main__":
|
| 27 |
+
unittest.main()
|
tests/test_insights.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
|
| 3 |
+
from shop_ledger.insights import (
|
| 4 |
+
answer_ledger_question,
|
| 5 |
+
anomaly_lantern_rows,
|
| 6 |
+
build_anomaly_lantern_markdown,
|
| 7 |
+
build_chart_plan,
|
| 8 |
+
build_chart_composer_markdown,
|
| 9 |
+
build_closing_ritual_markdown,
|
| 10 |
+
build_counterparty_memory_markdown,
|
| 11 |
+
build_daily_brief_markdown,
|
| 12 |
+
build_insight_figures,
|
| 13 |
+
build_timeline_markdown,
|
| 14 |
+
compute_metrics,
|
| 15 |
+
closing_checklist,
|
| 16 |
+
counterparty_memory_cards,
|
| 17 |
+
chart_spec_from_question,
|
| 18 |
+
daily_brief_fallback,
|
| 19 |
+
followup_rows,
|
| 20 |
+
review_rows,
|
| 21 |
+
risk_flags,
|
| 22 |
+
run_ledger_command,
|
| 23 |
+
timeline_figure,
|
| 24 |
+
timeline_rows,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
ROWS = [
|
| 29 |
+
{
|
| 30 |
+
"direction": "expense",
|
| 31 |
+
"payment_status": "paid",
|
| 32 |
+
"amount": 1200,
|
| 33 |
+
"currency": "LKR",
|
| 34 |
+
"counterparty": "Ravi",
|
| 35 |
+
"item": "rice bags",
|
| 36 |
+
"category": "inventory",
|
| 37 |
+
"confidence": 0.9,
|
| 38 |
+
},
|
| 39 |
+
{
|
| 40 |
+
"direction": "income",
|
| 41 |
+
"payment_status": "due",
|
| 42 |
+
"amount": 7500,
|
| 43 |
+
"currency": "LKR",
|
| 44 |
+
"counterparty": "Nimal",
|
| 45 |
+
"item": "tea packets",
|
| 46 |
+
"category": "sales",
|
| 47 |
+
"confidence": 0.8,
|
| 48 |
+
"reminder": "Follow up with Nimal about LKR 7,500.",
|
| 49 |
+
},
|
| 50 |
+
]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class InsightTests(unittest.TestCase):
|
| 54 |
+
def test_metrics_include_cash_and_followups(self):
|
| 55 |
+
metrics = compute_metrics(ROWS)
|
| 56 |
+
|
| 57 |
+
self.assertEqual(metrics["paid_expense"], 1200)
|
| 58 |
+
self.assertEqual(metrics["due_income"], 7500)
|
| 59 |
+
self.assertEqual(metrics["open_followups"], 1)
|
| 60 |
+
|
| 61 |
+
def test_followup_rows_include_script_and_priority(self):
|
| 62 |
+
queue = followup_rows(ROWS)
|
| 63 |
+
|
| 64 |
+
self.assertEqual(queue[0]["priority"], "High")
|
| 65 |
+
self.assertIn("Nimal", queue[0]["script"])
|
| 66 |
+
self.assertIn("polite_script", queue[0])
|
| 67 |
+
self.assertIn("friendly_script", queue[0])
|
| 68 |
+
self.assertIn("firm_script", queue[0])
|
| 69 |
+
self.assertIn("settle", queue[0]["firm_script"])
|
| 70 |
+
|
| 71 |
+
def test_risk_flags_include_high_value_due(self):
|
| 72 |
+
flags = risk_flags(ROWS)
|
| 73 |
+
|
| 74 |
+
self.assertTrue(any("High-value due item" in flag for flag in flags))
|
| 75 |
+
|
| 76 |
+
def test_chart_plan_prioritizes_due_followups(self):
|
| 77 |
+
plan = build_chart_plan(ROWS)
|
| 78 |
+
|
| 79 |
+
self.assertEqual(plan["chart"], "due_by_party")
|
| 80 |
+
self.assertIn("unpaid", plan["question"].lower())
|
| 81 |
+
|
| 82 |
+
def test_insight_figures_return_plotly_figures(self):
|
| 83 |
+
figures = build_insight_figures(ROWS)
|
| 84 |
+
|
| 85 |
+
self.assertEqual(len(figures), 3)
|
| 86 |
+
self.assertTrue(all(hasattr(figure, "to_plotly_json") for figure in figures))
|
| 87 |
+
|
| 88 |
+
def test_review_rows_include_low_confidence_entries(self):
|
| 89 |
+
rows = ROWS + [
|
| 90 |
+
{
|
| 91 |
+
"direction": "expense",
|
| 92 |
+
"payment_status": "",
|
| 93 |
+
"amount": 0,
|
| 94 |
+
"currency": "LKR",
|
| 95 |
+
"counterparty": "",
|
| 96 |
+
"item": "unknown",
|
| 97 |
+
"category": "uncategorized",
|
| 98 |
+
"confidence": 0.42,
|
| 99 |
+
}
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
queue = review_rows(rows)
|
| 103 |
+
|
| 104 |
+
self.assertEqual(queue[0]["source_row"], 3)
|
| 105 |
+
self.assertIn("Low confidence", queue[0]["issue"])
|
| 106 |
+
self.assertIn("confirm", queue[0]["question"])
|
| 107 |
+
|
| 108 |
+
def test_daily_brief_fallback_mentions_cash_and_followup(self):
|
| 109 |
+
brief = daily_brief_fallback(ROWS)
|
| 110 |
+
|
| 111 |
+
self.assertIn("Net cash", brief)
|
| 112 |
+
self.assertIn("Nimal", brief)
|
| 113 |
+
|
| 114 |
+
def test_daily_brief_markdown_wraps_model_name(self):
|
| 115 |
+
model_label = "unsloth/gemma-4-12b-it-GGUF / gemma-4-12b-it-UD-Q4_K_XL.gguf / llama.cpp"
|
| 116 |
+
markdown = build_daily_brief_markdown(ROWS, "Cash is tight today.", model_label)
|
| 117 |
+
|
| 118 |
+
self.assertIn("Today's Shop Pulse", markdown)
|
| 119 |
+
self.assertIn("unsloth/gemma-4-12b-it-GGUF", markdown)
|
| 120 |
+
|
| 121 |
+
def test_answer_ledger_question_answers_dues(self):
|
| 122 |
+
answer = answer_ledger_question(ROWS, "Who owes me most?")
|
| 123 |
+
|
| 124 |
+
self.assertIn("Nimal", answer)
|
| 125 |
+
self.assertIn("LKR 7,500", answer)
|
| 126 |
+
|
| 127 |
+
def test_answer_ledger_question_answers_cash_spend(self):
|
| 128 |
+
answer = answer_ledger_question(ROWS, "Where did cash go?")
|
| 129 |
+
|
| 130 |
+
self.assertIn("inventory", answer)
|
| 131 |
+
|
| 132 |
+
def test_timeline_rows_turn_entries_into_story_events(self):
|
| 133 |
+
events = timeline_rows(ROWS)
|
| 134 |
+
|
| 135 |
+
self.assertEqual(events[0]["source_row"], 1)
|
| 136 |
+
self.assertIn("Ravi", events[0]["story"])
|
| 137 |
+
self.assertEqual(events[1]["badge"], "Due")
|
| 138 |
+
|
| 139 |
+
def test_timeline_markdown_and_figure_render(self):
|
| 140 |
+
markdown = build_timeline_markdown(ROWS)
|
| 141 |
+
figure = timeline_figure(ROWS)
|
| 142 |
+
|
| 143 |
+
self.assertIn("Shop Pulse Timeline", markdown)
|
| 144 |
+
self.assertTrue(hasattr(figure, "to_plotly_json"))
|
| 145 |
+
|
| 146 |
+
def test_counterparty_memory_cards_surface_due_profile(self):
|
| 147 |
+
cards = counterparty_memory_cards(ROWS)
|
| 148 |
+
|
| 149 |
+
self.assertEqual(cards[0]["party"], "Nimal")
|
| 150 |
+
self.assertEqual(cards[0]["trust_pulse"], "Collect first")
|
| 151 |
+
self.assertIn("Follow up", cards[0]["next_message"])
|
| 152 |
+
|
| 153 |
+
def test_counterparty_memory_markdown_renders_cards(self):
|
| 154 |
+
markdown = build_counterparty_memory_markdown(ROWS)
|
| 155 |
+
|
| 156 |
+
self.assertIn("Counterparty Memory", markdown)
|
| 157 |
+
self.assertIn("Nimal", markdown)
|
| 158 |
+
|
| 159 |
+
def test_run_ledger_command_shows_unpaid(self):
|
| 160 |
+
output = run_ledger_command(ROWS, "Show unpaid")
|
| 161 |
+
|
| 162 |
+
self.assertIn("Unpaid", output)
|
| 163 |
+
self.assertIn("Nimal", output)
|
| 164 |
+
|
| 165 |
+
def test_run_ledger_command_prepares_quickbooks_plan(self):
|
| 166 |
+
output = run_ledger_command(ROWS, "Prepare QuickBooks export")
|
| 167 |
+
|
| 168 |
+
self.assertIn("QuickBooks", output)
|
| 169 |
+
self.assertIn("Customer/Vendor", output)
|
| 170 |
+
|
| 171 |
+
def test_chart_spec_from_question_selects_expense_chart(self):
|
| 172 |
+
spec = chart_spec_from_question(ROWS, "Where did cash go?")
|
| 173 |
+
|
| 174 |
+
self.assertEqual(spec["chart"], "expense_categories")
|
| 175 |
+
|
| 176 |
+
def test_chart_composer_markdown_names_chart(self):
|
| 177 |
+
markdown = build_chart_composer_markdown("Who owes?", {"chart": "due_by_party", "reason": "Dues", "model_used": "fake"})
|
| 178 |
+
|
| 179 |
+
self.assertIn("AI Chart Composer", markdown)
|
| 180 |
+
self.assertIn("Due radar", markdown)
|
| 181 |
+
|
| 182 |
+
def test_anomaly_lantern_flags_high_due_and_missing_amount(self):
|
| 183 |
+
rows = ROWS + [{"counterparty": "Saman", "item": "unknown", "amount": 0, "currency": "LKR", "confidence": 0.4}]
|
| 184 |
+
|
| 185 |
+
anomalies = anomaly_lantern_rows(rows)
|
| 186 |
+
|
| 187 |
+
self.assertTrue(any(item["signal"] == "High-value due" for item in anomalies))
|
| 188 |
+
self.assertTrue(any(item["signal"] == "Missing amount" for item in anomalies))
|
| 189 |
+
|
| 190 |
+
def test_anomaly_lantern_markdown_renders_cards(self):
|
| 191 |
+
markdown = build_anomaly_lantern_markdown(ROWS)
|
| 192 |
+
|
| 193 |
+
self.assertIn("Anomaly Lantern", markdown)
|
| 194 |
+
self.assertIn("High-value due", markdown)
|
| 195 |
+
|
| 196 |
+
def test_html_values_are_escaped_in_cards(self):
|
| 197 |
+
rows = [
|
| 198 |
+
{
|
| 199 |
+
"direction": "income",
|
| 200 |
+
"payment_status": "due",
|
| 201 |
+
"amount": 7500,
|
| 202 |
+
"currency": "LKR",
|
| 203 |
+
"counterparty": "<script>alert(1)</script>",
|
| 204 |
+
"item": "tea",
|
| 205 |
+
"category": "sales",
|
| 206 |
+
"confidence": 0.8,
|
| 207 |
+
"reminder": "Follow <script>alert(1)</script>",
|
| 208 |
+
}
|
| 209 |
+
]
|
| 210 |
+
|
| 211 |
+
markdown = build_anomaly_lantern_markdown(rows) + build_counterparty_memory_markdown(rows)
|
| 212 |
+
|
| 213 |
+
self.assertNotIn("<script>", markdown)
|
| 214 |
+
self.assertIn("<script>", markdown)
|
| 215 |
+
|
| 216 |
+
def test_closing_checklist_includes_export_step(self):
|
| 217 |
+
checklist = closing_checklist(ROWS)
|
| 218 |
+
|
| 219 |
+
self.assertTrue(any(item["step"] == "Export ledger" for item in checklist))
|
| 220 |
+
self.assertTrue(any(item["status"] == "Action" for item in checklist))
|
| 221 |
+
|
| 222 |
+
def test_closing_ritual_markdown_summarizes_day(self):
|
| 223 |
+
markdown = build_closing_ritual_markdown(ROWS)
|
| 224 |
+
|
| 225 |
+
self.assertIn("Daily Closing Ritual", markdown)
|
| 226 |
+
self.assertIn("Closing Checklist", markdown)
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
if __name__ == "__main__":
|
| 230 |
+
unittest.main()
|
tests/test_processor.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from unittest.mock import patch
|
| 3 |
+
|
| 4 |
+
from shop_ledger.llama_backend import LlamaLedgerBackend
|
| 5 |
+
from shop_ledger.processor import LedgerProcessor, resolve_model_path_from_env
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class ProcessorTests(unittest.TestCase):
|
| 9 |
+
def test_mock_processor_returns_rows(self):
|
| 10 |
+
processor = LedgerProcessor(mode="mock")
|
| 11 |
+
result = processor.process("paid Ravi 1200 for rice bags")
|
| 12 |
+
|
| 13 |
+
self.assertEqual(result.model_used, "mock heuristic")
|
| 14 |
+
self.assertEqual(len(result.entries), 1)
|
| 15 |
+
self.assertEqual(result.entries[0].amount, 1200)
|
| 16 |
+
|
| 17 |
+
def test_llama_mode_falls_back_without_model(self):
|
| 18 |
+
processor = LedgerProcessor(mode="llama", model_path="/missing/model.gguf")
|
| 19 |
+
result = processor.process("customer Nimal owes 750")
|
| 20 |
+
|
| 21 |
+
self.assertIn("fallback", result.model_used)
|
| 22 |
+
self.assertEqual(result.entries[0].amount, 750)
|
| 23 |
+
|
| 24 |
+
def test_llama_backend_uses_readable_model_label(self):
|
| 25 |
+
label = "unsloth/gemma-4-12b-it-GGUF / gemma-4-12b-it-UD-Q4_K_XL.gguf / llama.cpp"
|
| 26 |
+
with patch.dict("os.environ", {"LLAMA_MODEL_LABEL": label}):
|
| 27 |
+
backend = LlamaLedgerBackend(model_path="/models/model.gguf")
|
| 28 |
+
|
| 29 |
+
self.assertEqual(backend.model_label, label)
|
| 30 |
+
|
| 31 |
+
def test_env_model_path_wins_for_modal_mounts(self):
|
| 32 |
+
with patch.dict(
|
| 33 |
+
"os.environ",
|
| 34 |
+
{
|
| 35 |
+
"LLAMA_GGUF_PATH": "/models/model.gguf",
|
| 36 |
+
"LLAMA_GGUF_REPO": "unused/repo",
|
| 37 |
+
"LLAMA_GGUF_FILE": "unused.gguf",
|
| 38 |
+
},
|
| 39 |
+
):
|
| 40 |
+
self.assertEqual(resolve_model_path_from_env(), "/models/model.gguf")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
if __name__ == "__main__":
|
| 44 |
+
unittest.main()
|
tests/test_ui_input_choice.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from tempfile import NamedTemporaryFile
|
| 3 |
+
|
| 4 |
+
from shop_ledger.processor import extract_document_text, prepare_document_input
|
| 5 |
+
from shop_ledger.ui import (
|
| 6 |
+
add_to_ledger,
|
| 7 |
+
apply_row_correction,
|
| 8 |
+
ask_ledger,
|
| 9 |
+
ask_ledger_chat,
|
| 10 |
+
ask_ledger_voice_chat,
|
| 11 |
+
choose_input,
|
| 12 |
+
compose_chart,
|
| 13 |
+
generate_daily_brief,
|
| 14 |
+
initial_ask_chat,
|
| 15 |
+
run_command_palette,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class InputChoiceTests(unittest.TestCase):
|
| 20 |
+
def test_auto_asks_when_text_and_audio_exist(self):
|
| 21 |
+
choice = choose_input("paid Ravi 1200", "/tmp/audio.wav", None, "Auto")
|
| 22 |
+
|
| 23 |
+
self.assertEqual(choice["status"], "conflict")
|
| 24 |
+
self.assertIn("Multiple inputs", choice["notice"])
|
| 25 |
+
|
| 26 |
+
def test_text_choice_uses_text_when_audio_exists(self):
|
| 27 |
+
choice = choose_input("paid Ravi 1200", "/tmp/audio.wav", None, "Text note")
|
| 28 |
+
|
| 29 |
+
self.assertEqual(choice["status"], "ready")
|
| 30 |
+
self.assertEqual(choice["source"], "text")
|
| 31 |
+
|
| 32 |
+
def test_auto_uses_audio_when_audio_is_only_input(self):
|
| 33 |
+
choice = choose_input("", "/tmp/audio.wav", None, "Auto")
|
| 34 |
+
|
| 35 |
+
self.assertEqual(choice["status"], "ready")
|
| 36 |
+
self.assertEqual(choice["source"], "audio")
|
| 37 |
+
|
| 38 |
+
def test_auto_uses_document_when_document_is_only_input(self):
|
| 39 |
+
choice = choose_input("", None, "/tmp/receipt.pdf", "Auto")
|
| 40 |
+
|
| 41 |
+
self.assertEqual(choice["status"], "ready")
|
| 42 |
+
self.assertEqual(choice["source"], "document")
|
| 43 |
+
|
| 44 |
+
def test_document_text_extraction_reads_plain_text_files(self):
|
| 45 |
+
with NamedTemporaryFile("w", suffix=".txt") as handle:
|
| 46 |
+
handle.write("paid Ravi 1200 for rice bags")
|
| 47 |
+
handle.flush()
|
| 48 |
+
|
| 49 |
+
text = extract_document_text(handle.name)
|
| 50 |
+
|
| 51 |
+
self.assertIn("Ravi", text)
|
| 52 |
+
|
| 53 |
+
def test_document_image_preparation_creates_data_url(self):
|
| 54 |
+
from PIL import Image
|
| 55 |
+
|
| 56 |
+
with NamedTemporaryFile(suffix=".png") as handle:
|
| 57 |
+
Image.new("RGB", (8, 8), color="white").save(handle.name)
|
| 58 |
+
|
| 59 |
+
document = prepare_document_input(handle.name)
|
| 60 |
+
|
| 61 |
+
self.assertEqual(document["kind"], "image")
|
| 62 |
+
self.assertTrue(document["image_urls"][0].startswith("data:image/jpeg;base64,"))
|
| 63 |
+
|
| 64 |
+
def test_successful_text_add_clears_written_note(self):
|
| 65 |
+
def fake_process(note, currency, image_urls=None):
|
| 66 |
+
return {
|
| 67 |
+
"entries": [
|
| 68 |
+
{
|
| 69 |
+
"date": "2026-06-11",
|
| 70 |
+
"direction": "expense",
|
| 71 |
+
"counterparty": "Ravi",
|
| 72 |
+
"item": "rice bags",
|
| 73 |
+
"quantity": "",
|
| 74 |
+
"amount": 1200,
|
| 75 |
+
"currency": currency,
|
| 76 |
+
"category": "inventory",
|
| 77 |
+
"payment_status": "paid",
|
| 78 |
+
"due_date": "",
|
| 79 |
+
"confidence": 0.9,
|
| 80 |
+
"reminder": "",
|
| 81 |
+
}
|
| 82 |
+
],
|
| 83 |
+
"reminders": [],
|
| 84 |
+
"questions": [],
|
| 85 |
+
"model_used": "fake",
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
output = add_to_ledger("paid Ravi 1200", None, None, "Auto", "LKR", [], fake_process)
|
| 89 |
+
|
| 90 |
+
self.assertEqual(len(output[6]), 1)
|
| 91 |
+
self.assertEqual(output[7]["value"], "")
|
| 92 |
+
self.assertEqual(output[10]["value"], "Auto")
|
| 93 |
+
self.assertIn("Added 1 row", output[11])
|
| 94 |
+
|
| 95 |
+
def test_successful_document_add_sends_image_urls_and_clears_file(self):
|
| 96 |
+
captured = {}
|
| 97 |
+
|
| 98 |
+
def fake_process(note, currency, image_urls=None):
|
| 99 |
+
captured["note"] = note
|
| 100 |
+
captured["image_urls"] = image_urls
|
| 101 |
+
return {
|
| 102 |
+
"entries": [
|
| 103 |
+
{
|
| 104 |
+
"date": "2026-06-11",
|
| 105 |
+
"direction": "expense",
|
| 106 |
+
"counterparty": "Ravi",
|
| 107 |
+
"item": "rice bags",
|
| 108 |
+
"quantity": "",
|
| 109 |
+
"amount": 1200,
|
| 110 |
+
"currency": currency,
|
| 111 |
+
"category": "inventory",
|
| 112 |
+
"payment_status": "paid",
|
| 113 |
+
"due_date": "",
|
| 114 |
+
"confidence": 0.9,
|
| 115 |
+
"reminder": "",
|
| 116 |
+
}
|
| 117 |
+
],
|
| 118 |
+
"reminders": [],
|
| 119 |
+
"questions": [],
|
| 120 |
+
"model_used": "fake",
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
with NamedTemporaryFile("w", suffix=".txt") as handle:
|
| 124 |
+
handle.write("paid Ravi 1200 for rice bags")
|
| 125 |
+
handle.flush()
|
| 126 |
+
|
| 127 |
+
output = add_to_ledger("", None, handle.name, "Document", "LKR", [], fake_process)
|
| 128 |
+
|
| 129 |
+
self.assertIn("paid Ravi", captured["note"])
|
| 130 |
+
self.assertIsNone(captured["image_urls"])
|
| 131 |
+
self.assertEqual(output[9]["value"], None)
|
| 132 |
+
self.assertIn("Added 1 row", output[11])
|
| 133 |
+
|
| 134 |
+
def test_generate_daily_brief_uses_supplied_function(self):
|
| 135 |
+
rows = [{"amount": 1200, "currency": "LKR", "direction": "expense", "payment_status": "paid"}]
|
| 136 |
+
|
| 137 |
+
markdown = generate_daily_brief(
|
| 138 |
+
rows,
|
| 139 |
+
"LKR",
|
| 140 |
+
lambda supplied_rows, currency: {"brief": f"{len(supplied_rows)} rows in {currency}", "model_used": "fake"},
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
self.assertIn("1 rows in LKR", markdown)
|
| 144 |
+
self.assertIn("fake", markdown)
|
| 145 |
+
|
| 146 |
+
def test_ask_ledger_uses_supplied_function(self):
|
| 147 |
+
rows = [{"amount": 7500, "currency": "LKR", "payment_status": "due"}]
|
| 148 |
+
|
| 149 |
+
markdown = ask_ledger(
|
| 150 |
+
rows,
|
| 151 |
+
"Who owes me most?",
|
| 152 |
+
"LKR",
|
| 153 |
+
lambda supplied_rows, question, currency: {"answer": f"{question} / {len(supplied_rows)}", "model_used": "fake"},
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
self.assertIn("Who owes me most?", markdown)
|
| 157 |
+
self.assertIn("fake", markdown)
|
| 158 |
+
|
| 159 |
+
def test_ask_ledger_chat_appends_messages_and_clears_input(self):
|
| 160 |
+
rows = [{"amount": 7500, "currency": "LKR", "payment_status": "due"}]
|
| 161 |
+
|
| 162 |
+
history, next_question = ask_ledger_chat(
|
| 163 |
+
rows,
|
| 164 |
+
"Who owes me most?",
|
| 165 |
+
initial_ask_chat(),
|
| 166 |
+
"LKR",
|
| 167 |
+
lambda supplied_rows, question, currency: {"answer": "Nimal owes LKR 7,500.", "model_used": "fake"},
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
self.assertEqual(next_question, "")
|
| 171 |
+
self.assertEqual(history[-2]["role"], "user")
|
| 172 |
+
self.assertEqual(history[-1]["role"], "assistant")
|
| 173 |
+
self.assertIn("Nimal", history[-1]["content"])
|
| 174 |
+
|
| 175 |
+
def test_ask_ledger_voice_chat_transcribes_and_answers(self):
|
| 176 |
+
history, next_question, next_audio = ask_ledger_voice_chat(
|
| 177 |
+
[{"counterparty": "Nimal", "amount": 7500, "payment_status": "due", "currency": "LKR"}],
|
| 178 |
+
"/tmp/question.wav",
|
| 179 |
+
initial_ask_chat(),
|
| 180 |
+
"LKR",
|
| 181 |
+
lambda rows, question, currency: {"answer": f"Answered: {question}", "model_used": "fake"},
|
| 182 |
+
transcribe_fn=lambda path: "Who owes me most?",
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
self.assertEqual(next_question, "")
|
| 186 |
+
self.assertIsNone(next_audio)
|
| 187 |
+
self.assertIn("Who owes me most?", history[-2]["content"])
|
| 188 |
+
self.assertIn("Answered", history[-1]["content"])
|
| 189 |
+
|
| 190 |
+
def test_ask_ledger_voice_chat_handles_empty_transcript(self):
|
| 191 |
+
history, _, next_audio = ask_ledger_voice_chat(
|
| 192 |
+
[],
|
| 193 |
+
"/tmp/question.wav",
|
| 194 |
+
initial_ask_chat(),
|
| 195 |
+
"LKR",
|
| 196 |
+
lambda rows, question, currency: {"answer": "unused", "model_used": "fake"},
|
| 197 |
+
transcribe_fn=lambda path: "",
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
self.assertIsNone(next_audio)
|
| 201 |
+
self.assertIn("could not hear", history[-1]["content"])
|
| 202 |
+
|
| 203 |
+
def test_run_command_palette_uses_current_rows(self):
|
| 204 |
+
rows = [{"payment_status": "due", "counterparty": "Nimal", "amount": 7500, "currency": "LKR", "item": "tea"}]
|
| 205 |
+
|
| 206 |
+
output = run_command_palette(rows, "Show unpaid")
|
| 207 |
+
|
| 208 |
+
self.assertIn("Nimal", output)
|
| 209 |
+
|
| 210 |
+
def test_compose_chart_returns_markdown_figure_and_clears_input(self):
|
| 211 |
+
rows = [{"payment_status": "due", "counterparty": "Nimal", "amount": 7500, "currency": "LKR"}]
|
| 212 |
+
|
| 213 |
+
markdown, figure, next_question = compose_chart(
|
| 214 |
+
rows,
|
| 215 |
+
"Who owes me?",
|
| 216 |
+
lambda supplied_rows, question: {"chart": "due_by_party", "reason": "Dues", "model_used": "fake"},
|
| 217 |
+
)
|
| 218 |
+
|
| 219 |
+
self.assertIn("Due radar", markdown)
|
| 220 |
+
self.assertTrue(hasattr(figure, "to_plotly_json"))
|
| 221 |
+
self.assertEqual(next_question, "")
|
| 222 |
+
|
| 223 |
+
def test_apply_row_correction_updates_state_and_confidence(self):
|
| 224 |
+
rows = [
|
| 225 |
+
{
|
| 226 |
+
"date": "2026-06-11",
|
| 227 |
+
"direction": "income",
|
| 228 |
+
"counterparty": "",
|
| 229 |
+
"item": "tea packets",
|
| 230 |
+
"quantity": "",
|
| 231 |
+
"amount": 750,
|
| 232 |
+
"currency": "LKR",
|
| 233 |
+
"category": "sales",
|
| 234 |
+
"payment_status": "due",
|
| 235 |
+
"due_date": "",
|
| 236 |
+
"confidence": 0.42,
|
| 237 |
+
"reminder": "",
|
| 238 |
+
}
|
| 239 |
+
]
|
| 240 |
+
|
| 241 |
+
output = apply_row_correction(rows, 1, "counterparty", "Nimal", "LKR")
|
| 242 |
+
|
| 243 |
+
updated_rows = output[6]
|
| 244 |
+
self.assertEqual(updated_rows[0]["counterparty"], "Nimal")
|
| 245 |
+
self.assertEqual(updated_rows[0]["confidence"], 0.9)
|
| 246 |
+
self.assertIn("Updated row 1", output[-1])
|
| 247 |
+
|
| 248 |
+
def test_apply_row_correction_rejects_bad_amount(self):
|
| 249 |
+
rows = [
|
| 250 |
+
{
|
| 251 |
+
"date": "2026-06-11",
|
| 252 |
+
"direction": "income",
|
| 253 |
+
"counterparty": "Nimal",
|
| 254 |
+
"item": "tea packets",
|
| 255 |
+
"quantity": "",
|
| 256 |
+
"amount": 750,
|
| 257 |
+
"currency": "LKR",
|
| 258 |
+
"category": "sales",
|
| 259 |
+
"payment_status": "due",
|
| 260 |
+
"due_date": "",
|
| 261 |
+
"confidence": 0.42,
|
| 262 |
+
"reminder": "",
|
| 263 |
+
}
|
| 264 |
+
]
|
| 265 |
+
|
| 266 |
+
output = apply_row_correction(rows, 1, "amount", "many rupees", "LKR")
|
| 267 |
+
|
| 268 |
+
self.assertEqual(output[6][0]["amount"], 750)
|
| 269 |
+
self.assertIn("need a number", output[-1])
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
if __name__ == "__main__":
|
| 273 |
+
unittest.main()
|