keshan commited on
Commit
3e02e4b
·
verified ·
1 Parent(s): b5d5fe8

Submit Small Shop Ledger to Build Small Hackathon

Browse files
.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: red
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: 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("&lt;script&gt;", 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()