Aarya2004 Claude Opus 4.8 (1M context) commited on
Commit
47b2a99
·
1 Parent(s): 76a2f92

Deploy: sync hosted Space to local app (chat, document capture, Modal backends, pages, mobile/QR)

Browse files

Brings the stub Docker Space up to the current app: conversational chat
edits, Document Capture, Saved Estimates, dashboard/jobs/inventory pages,
mobile capture, and the Modal backend modules so the Space can be wired
to live GPU models via secrets (FF_BACKEND + FF_MODAL_*_URL).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. CONTEXT.md +35 -1
  2. Dockerfile +1 -1
  3. README.md +138 -2
  4. data/recall_evalset.json +133 -0
  5. data/sample_inventory.json +69 -0
  6. package-lock.json +2 -2
  7. package.json +7 -1
  8. pyproject.toml +25 -0
  9. quillwright/agent.py +25 -6
  10. quillwright/api/chat.py +551 -0
  11. quillwright/api/document.py +69 -0
  12. quillwright/api/estimate.py +64 -8
  13. quillwright/api/export.py +18 -0
  14. quillwright/api/pages.py +94 -0
  15. quillwright/api/pdf_links.py +40 -0
  16. quillwright/api/qr.py +24 -0
  17. quillwright/api/recalc.py +11 -1
  18. quillwright/api/send.py +285 -0
  19. quillwright/api/tools_api.py +151 -0
  20. quillwright/api/transcribe.py +35 -0
  21. quillwright/api/voice.py +429 -0
  22. quillwright/backends/audio.py +92 -0
  23. quillwright/backends/embedding.py +30 -0
  24. quillwright/backends/modal.py +202 -0
  25. quillwright/backends/modal_app.py +103 -0
  26. quillwright/backends/modal_aya_app.py +73 -0
  27. quillwright/backends/modal_omni_app.py +101 -0
  28. quillwright/backends/modal_parse_app.py +139 -0
  29. quillwright/backends/parse.py +156 -0
  30. quillwright/brain_loop.py +7 -2
  31. quillwright/estimate_store.py +91 -0
  32. quillwright/memory.py +42 -4
  33. quillwright/models.py +22 -1
  34. quillwright/pairing.py +63 -0
  35. quillwright/recall_eval.py +98 -0
  36. quillwright/resolver.py +141 -5
  37. quillwright/server.py +438 -9
  38. quillwright/theme.css +90 -17
  39. quillwright/thread.py +33 -0
  40. quillwright/tools.py +38 -4
  41. quillwright/web/css/pages.css +217 -0
  42. quillwright/web/css/theme.css +2 -0
  43. quillwright/web/css/workspace.css +814 -69
  44. quillwright/web/dashboard.html +75 -0
  45. quillwright/web/estimates.html +74 -0
  46. quillwright/web/index.html +159 -5
  47. quillwright/web/inventory.html +73 -0
  48. quillwright/web/jobs.html +71 -0
  49. quillwright/web/js/client.js +119 -0
  50. quillwright/web/js/dashboard.js +59 -0
CONTEXT.md CHANGED
@@ -12,6 +12,14 @@ _Avoid_: upload, input, submission
12
  A single fact the perception model extracts from a photo — an item, part, damage, or piece of read text (e.g. a nameplate model number).
13
  _Avoid_: detection, finding, result
14
 
 
 
 
 
 
 
 
 
15
  **Agent Brain**:
16
  The orchestrator model that runs the plan → act → self-check loop and decides which Tools to call. There is exactly one.
17
  _Avoid_: orchestrator, controller, LLM
@@ -21,7 +29,7 @@ A callable capability the Agent Brain invokes. Tools are where facts (prices, ma
21
  _Avoid_: function, plugin, skill
22
 
23
  **Facts-from-Tools**:
24
- The correctness rule: any number that reaches the customer (price, quantity, markup, tax, total) must come from a Tool (`lookup_price`, `compute`) or user-confirmed data — never from the Agent Brain's free generation.
25
  _Avoid_: no-hallucination (too vague)
26
 
27
  **Line Item**:
@@ -84,6 +92,26 @@ _Avoid_: history, log, cache
84
  The agent retrieving relevant past Runs from Episodic Memory via hybrid search — a keyword/structured pre-filter then a semantic re-rank — exposed as the `search_past_jobs` Tool.
85
  _Avoid_: lookup, query, retrieval (use "Recall" for this specific agent-facing capability)
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  ## Relationships
88
 
89
  - A **Capture** is turned into **Observations** by the Perception **Model Role**.
@@ -91,6 +119,9 @@ _Avoid_: lookup, query, retrieval (use "Recall" for this specific agent-facing c
91
  - Every **Model Role** resolves to a concrete model based on the active **Mode**.
92
  - The **Agent Brain** emits a **Trace** of its steps.
93
  - A human supervises: confirms **Observations**, answers low-confidence prompts, edits the **Deliverable** before export.
 
 
 
94
 
95
  ## Flagged ambiguities
96
 
@@ -99,6 +130,9 @@ _Avoid_: lookup, query, retrieval (use "Recall" for this specific agent-facing c
99
  - "adapt prices" was ambiguous — resolved: deterministic learning from user-confirmed edits/prefs only; novel items are flagged, never LLM-guessed (**Facts-from-Tools**).
100
  - "translate tool vs language toggle" overlapped — resolved: one underlying translate function, two entry points (human toggle + autonomous agent call).
101
  - "Local/on-device" (the hero) vs hosting reality — resolved: real compute on Modal; "small/private" = open models + no third-party APIs; literal offline = the filmed **Airplane-Mode Proof**.
 
 
 
102
 
103
  ## Scope note
104
 
 
12
  A single fact the perception model extracts from a photo — an item, part, damage, or piece of read text (e.g. a nameplate model number).
13
  _Avoid_: detection, finding, result
14
 
15
+ **Document Capture**:
16
+ A second capture path: a document the tech or customer hands over — a spec sheet, supplier quote, or old written estimate — read by the Extraction Model Role (Nemotron Parse) into structured text + tables that feed the same Estimate pipeline. Distinct from a job-site photo (different input, different model); entered via its own control, never auto-classified.
17
+ _Avoid_: scan, OCR (Document Capture is the agent-facing capability; OCR is one mechanism)
18
+
19
+ **Proposed Line Item**:
20
+ A Line Item whose price came from a Document Capture, not the catalog — surfaced to the human as a proposal to confirm or edit before it enters the Estimate. The document is the _source_, but the price only becomes customer-facing once a human confirms it (an Agent Pause), preserving Facts-from-Tools.
21
+ _Avoid_: draft line (a Line Item is already a draft; "Proposed" specifically means awaiting human confirmation of a document-read price)
22
+
23
  **Agent Brain**:
24
  The orchestrator model that runs the plan → act → self-check loop and decides which Tools to call. There is exactly one.
25
  _Avoid_: orchestrator, controller, LLM
 
29
  _Avoid_: function, plugin, skill
30
 
31
  **Facts-from-Tools**:
32
+ The correctness rule: any number that reaches the customer (price, quantity, markup, tax, total) must come from a Tool (`lookup_price`, `compute`) or user-confirmed data — never from the Agent Brain's free generation. This extends to **Document Capture**: a price read off a document by Nemotron Parse is a _model_ output, so it is never used directly — it becomes a Proposed Line Item the human confirms (the document is the source; the human is the gate).
33
  _Avoid_: no-hallucination (too vague)
34
 
35
  **Line Item**:
 
92
  The agent retrieving relevant past Runs from Episodic Memory via hybrid search — a keyword/structured pre-filter then a semantic re-rank — exposed as the `search_past_jobs` Tool.
93
  _Avoid_: lookup, query, retrieval (use "Recall" for this specific agent-facing capability)
94
 
95
+ **Account**:
96
+ The single owner of all stored data — saved Estimates, their Refinement Threads, and Profile Memory. The product targets solo tradespeople, so an Account == the one Tech == the business; there is no organization layer and no multi-tech sharing. Multi-tenant-_shaped_ (everything is keyed by `account_id`) but bound to one fixed demo Account (`account_id = "demo"`) — no login, no auth. A real login that swaps the fixed key for a session lookup is a deliberate post-hackathon extension (ADR-0013).
97
+ _Avoid_: User, organization, workspace (Workspace is the screen), Customer (the homeowner the Estimate is _sent to_ — a different party)
98
+
99
+ **Saved Estimate**:
100
+ An Estimate persisted to the Estimate Store under an Account so the Tech can reopen, edit, and re-export it across sessions — distinct from a Run (the lossy Episodic-Memory record that feeds Recall). Auto-saved when the forge finishes and explicitly Save-able mid-draft; updated in place on edit; deleted by Discard. No "finalized/locked" status (an Estimate is not an invoice).
101
+ _Avoid_: invoice, quote (an Estimate stays an editable draft), Run (a Run is the agent's memory record, not a reopenable Deliverable)
102
+
103
+ **Estimate Store**:
104
+ The per-Account store of Saved Estimates + their Refinement Threads — separate from Episodic Memory (which stays a pure, append-only Recall corpus). The two have different jobs: Episodic Memory serves the _agent_ (gets smarter), the Estimate Store serves the _user_ (file and reopen work).
105
+ _Avoid_: database, history (Episodic Memory is the history-for-the-agent)
106
+
107
+ **Refinement Thread**:
108
+ The persisted, ordered record of post-forge chat turns for one Saved Estimate (human message + the operation taken, e.g. "set labor to 2h → qty 2"). Stored _sanitized_ — intents and operations only, never dollar figures — so resuming the conversation can never feed a stale number back to the model (Facts-from-Tools). A sibling of Trace, not part of it: Trace is the Agent Brain's forge steps; the Refinement Thread is the human-driven editing conversation that follows.
109
+ _Avoid_: chat log, conversation history, Trace (Trace is the forge-step record)
110
+
111
+ **Thread Compaction**:
112
+ Keeping a long Refinement Thread inside the small model's context window by folding the oldest turns into one mechanical "earlier in this estimate: …" line — done _deterministically in code_ from the stored operations (the last K turns stay verbatim), never by asking a model to summarize. A second instance of Facts-from-Tools: even the conversation's own compression is code-owned, so the model never restates its own pricing history.
113
+ _Avoid_: summarization (implies a model writes it — it does not), truncation (compaction folds, it doesn't drop facts)
114
+
115
  ## Relationships
116
 
117
  - A **Capture** is turned into **Observations** by the Perception **Model Role**.
 
119
  - Every **Model Role** resolves to a concrete model based on the active **Mode**.
120
  - The **Agent Brain** emits a **Trace** of its steps.
121
  - A human supervises: confirms **Observations**, answers low-confidence prompts, edits the **Deliverable** before export.
122
+ - An **Account** owns its **Saved Estimates**, **Profile Memory**, and **Episodic Memory**; everything stored is keyed by `account_id` (one fixed demo Account in the hackathon).
123
+ - A **Saved Estimate** lives in the **Estimate Store** and has one **Refinement Thread** (its post-forge chat turns); the Thread is sanitized and **Thread Compaction** keeps it bounded.
124
+ - The **Refinement Thread** is replayed to the **Agent Brain** on resume for reference (pronoun) resolution; the model gets sanitized history for _context_ but the **current Line Items** for _numbers_ — never historical dollars (**Facts-from-Tools**).
125
 
126
  ## Flagged ambiguities
127
 
 
130
  - "adapt prices" was ambiguous — resolved: deterministic learning from user-confirmed edits/prefs only; novel items are flagged, never LLM-guessed (**Facts-from-Tools**).
131
  - "translate tool vs language toggle" overlapped — resolved: one underlying translate function, two entry points (human toggle + autonomous agent call).
132
  - "Local/on-device" (the hero) vs hosting reality — resolved: real compute on Modal; "small/private" = open models + no third-party APIs; literal offline = the filmed **Airplane-Mode Proof**.
133
+ - "account" (previously listed as a term to _avoid_ under **Customer**) — resolved: **Account** is now the data-owner term (the Tech/business), distinct from **Customer** (the homeowner an Estimate is sent to). For a solo-tradesperson product, Account == Tech == business; no organization layer.
134
+ - "remember the chat" was ambiguous (audit trail vs resumable) — resolved: the **Refinement Thread** is both _persisted_ (reopen shows it) and _resumable_ (replayed to the model on continue), but **sanitized** (no dollars) with numbers always taken live from current Line Items. Cross-session resume is bounded by deterministic **Thread Compaction**.
135
+ - "chat history vs Trace" overlapped — resolved: **Trace** = the Agent Brain's forge steps; the **Refinement Thread** = the human-driven post-forge editing conversation. Siblings, separately stored.
136
 
137
  ## Scope note
138
 
Dockerfile CHANGED
@@ -1,4 +1,4 @@
1
- # FieldForge HF Space — Docker SDK.
2
  # Runs the bespoke gr.Server (FastAPI) app in STUB mode (no GPU, no Ollama): the contest
3
  # requires a Gradio Space underneath, and Docker SDK is sanctioned. Real models reach the
4
  # hosted Space via Modal later (ADR-0005); this image needs neither GPU nor model weights.
 
1
+ # Quillwright HF Space — Docker SDK.
2
  # Runs the bespoke gr.Server (FastAPI) app in STUB mode (no GPU, no Ollama): the contest
3
  # requires a Gradio Space underneath, and Docker SDK is sanctioned. Real models reach the
4
  # hosted Space via Modal later (ADR-0005); this image needs neither GPU nor model weights.
README.md CHANGED
@@ -18,6 +18,13 @@ tags:
18
 
19
  A human-supervised, small-model agent for tradespeople: snap a job photo + voice note → a team of **local** small models forges a finished, itemized **estimate**. No cloud, runs on your machine. Build Small Hackathon entry (Backyard AI track).
20
 
 
 
 
 
 
 
 
21
  > **This hosted Space runs in stub mode** (CPU, no GPU): the agent flow, trace, editable estimate, and PDF all work, but the small models are stubbed. The real models (MiniCPM-V, Nemotron, Aya) run locally via Ollama — see the demo video / Airplane-Mode Proof for them in action. Live models reach the hosted Space via Modal (in progress).
22
 
23
  See `docs/superpowers/specs/` and `docs/adr/` for the design.
@@ -28,6 +35,8 @@ See `docs/superpowers/specs/` and `docs/adr/` for the design.
28
  - **Brain** — Nemotron-3-Nano (NVIDIA) drives the tool-calling agent loop (which items, quantities, when done), locally via Ollama. Tuned to ~0.97 item-F1 on the eval set (`scripts/run_brain_eval.py`).
29
  - **Facts-from-Tools** — every price/total comes from the catalog + deterministic `compute`, never the LLM. Holds even for human edits.
30
  - **Human-in-the-loop** — the agent pauses to ask when a price is missing; you answer and it resumes.
 
 
31
  - **Frontend** — a bespoke web UI served by `gradio.Server` (FastAPI under the hood): streaming "Digital Apprentice" trace, editable estimate, PDF export.
32
 
33
  ## Run
@@ -46,15 +55,142 @@ ollama pull nemotron-3-nano:4b
46
  FF_REAL_MODELS=1 python -m quillwright.server
47
  ```
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  ## Test
50
 
51
  ```
52
  pytest -v
53
- ruff check . && ruff format --check . # Python lint/format
54
- npx prettier --check "quillwright/web/**/*" # web lint/format
 
55
 
56
  # brain accuracy against the eval set (needs Ollama + FF_REAL_MODELS=1)
57
  FF_REAL_MODELS=1 PYTHONPATH=. python scripts/run_brain_eval.py
58
  ```
59
 
 
 
 
 
60
  Models resolve per role via `quillwright/resolver.py` (stub ↔ Ollama). Pricing is clearly-labeled sample data.
 
18
 
19
  A human-supervised, small-model agent for tradespeople: snap a job photo + voice note → a team of **local** small models forges a finished, itemized **estimate**. No cloud, runs on your machine. Build Small Hackathon entry (Backyard AI track).
20
 
21
+ > **⏳ Cold start (please wait ~30–60s on first load).** This Space scales to zero when idle,
22
+ > so the **first** visit after a quiet period has to boot the container before the app
23
+ > responds — you may see Hugging Face's "Building / Starting" screen, then a moment where
24
+ > the page is warming up. **The app is not broken — it's waking up.** Once it's up it's
25
+ > instant (it runs in stub mode on CPU, so there's no model to load). Reload once if the
26
+ > first paint hangs; the UI shows a "waking up → ready" banner when it reconnects.
27
+
28
  > **This hosted Space runs in stub mode** (CPU, no GPU): the agent flow, trace, editable estimate, and PDF all work, but the small models are stubbed. The real models (MiniCPM-V, Nemotron, Aya) run locally via Ollama — see the demo video / Airplane-Mode Proof for them in action. Live models reach the hosted Space via Modal (in progress).
29
 
30
  See `docs/superpowers/specs/` and `docs/adr/` for the design.
 
35
  - **Brain** — Nemotron-3-Nano (NVIDIA) drives the tool-calling agent loop (which items, quantities, when done), locally via Ollama. Tuned to ~0.97 item-F1 on the eval set (`scripts/run_brain_eval.py`).
36
  - **Facts-from-Tools** — every price/total comes from the catalog + deterministic `compute`, never the LLM. Holds even for human edits.
37
  - **Human-in-the-loop** — the agent pauses to ask when a price is missing; you answer and it resumes.
38
+ - **Saved Estimates** — per-account persistence: auto-save on forge, reopen from "My Estimates", resume the (sanitized) refinement chat (ADR-0013).
39
+ - **Phone capture** — call a Twilio number (it forges a draft + texts the PDF) or scan a QR to capture a photo + voice note on your phone and forge live on the desktop.
40
  - **Frontend** — a bespoke web UI served by `gradio.Server` (FastAPI under the hood): streaming "Digital Apprentice" trace, editable estimate, PDF export.
41
 
42
  ## Run
 
55
  FF_REAL_MODELS=1 python -m quillwright.server
56
  ```
57
 
58
+ ### Backend resolution — read this before "am I on Modal?"
59
+
60
+ There is no single local/Modal switch. `FF_REAL_MODELS=1` is the master gate out of
61
+ stub mode; backends then resolve **per role** (see `quillwright/resolver.py`), and a
62
+ few roles can only go one way:
63
+
64
+ | Role | Stub (default) | Local (Ollama) | Modal (hosted Best Stack) |
65
+ | ----------------------------------------- | -------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
66
+ | **brain** (agent loop) | scripted | Nemotron-3-Nano **4B** | Nemotron-3-Nano **30B** — set `FF_BACKEND=modal` + `FF_MODAL_BRAIN_URL` |
67
+ | **perception** (vision) | scripted | MiniCPM-V | Nemotron **Omni** — additionally set `FF_MODAL_OMNI_URL` (else stays on Ollama) |
68
+ | **audio** (voice note) | scripted | Cohere Transcribe on-device (transformers) | Nemotron **Omni** (same deployment as perception) — `FF_MODAL_OMNI_URL` |
69
+ | **multilingual** | scripted | Aya | **Aya Expanse 8B** — additionally set `FF_MODAL_AYA_URL` (else stays on Ollama) |
70
+ | **embedding** | scripted | on-device (sentence-transformers) — same path for any non-stub backend | _same on-device path_ |
71
+ | **extraction** (Document Capture / Parse) | scripted | _no local path_ (>30GB RAM on Apple Silicon) | **Modal only**, always remote — needs `FF_MODAL_PARSE_URL` |
72
+
73
+ How the switches compose:
74
+
75
+ - **`FF_BACKEND=modal` by itself moves only the _brain_ to Modal** (its URL is then
76
+ required — missing `FF_MODAL_BRAIN_URL` fails loud, never silently downgrades).
77
+ - **Each other role opts in per-URL**: with `FF_BACKEND=modal` set, perception/audio
78
+ upgrade to the hosted Omni only when `FF_MODAL_OMNI_URL` is also set, multilingual to
79
+ Aya Expanse only when `FF_MODAL_AYA_URL` is set. Unset URLs keep the local/on-device
80
+ path working — deploying one GPU app never breaks the roles you didn't deploy.
81
+ - **Parse keys off its own `FF_MODAL_PARSE_URL`, independent of `FF_BACKEND`.** So you can
82
+ run a local Ollama brain _and_ hit Modal Parse at the same time — "am I on Modal?" is not
83
+ a single yes/no. This is intentional: Parse has no local serving path (ADR-0011), but it
84
+ means the offline ("Airplane-Mode") story only holds while every `FF_MODAL_*_URL` is unset.
85
+
86
+ ### Wiring the hosted Space to Modal (live real models)
87
+
88
+ The Space can serve the real models on Modal GPUs — no tunnel involved (Modal apps are
89
+ public HTTPS endpoints; the Space just calls them). The apps are deployed and scale to zero;
90
+ wiring is purely Space **secrets** (Settings → Variables and secrets):
91
+
92
+ | Secret | Value | Effect |
93
+ | -------------------- | ------------------------------- | ------------------------------------------------- |
94
+ | `FF_BACKEND` | `modal` | moves the **brain** to Modal (Nemotron 30B) |
95
+ | `FF_MODAL_BRAIN_URL` | `https://<brain-app>.modal.run` | **required** when `FF_BACKEND=modal` (fails loud) |
96
+ | `FF_MODAL_OMNI_URL` | `https://<omni-app>.modal.run` | upgrades **vision + audio** to hosted Omni |
97
+ | `FF_MODAL_AYA_URL` | `https://<aya-app>.modal.run` | upgrades **multilingual** to Aya Expanse |
98
+ | `FF_MODAL_PARSE_URL` | `https://<parse-app>.modal.run` | enables **Document Capture** (Parse; Modal-only) |
99
+
100
+ Get the URLs from `modal app list` / each app's deployed endpoint. Each role opts in per-URL;
101
+ unset URLs keep that role on its non-Modal path.
102
+
103
+ > **⏳ Model cold-start.** The first request to each Modal app pays a GPU cold-start — up to a
104
+ > **minute or two for the 30B brain**. The app is warming, not broken: the UI shows a "Waking
105
+ > the models" card on the first forge whenever real models are in play. **Warm the apps before
106
+ > a live demo** (hit each once). When `FF_BACKEND` is unset the Space runs in instant CPU stub
107
+ > mode (the default for the public submission link).
108
+
109
+ > **💸 Cost.** A judge-clickable hosted GPU can spend over the whole judging window. The apps
110
+ > are set to **scale to zero** when idle; confirm that before leaving the Space Modal-wired,
111
+ > and don't leave apps you only warmed for the demo serving afterwards.
112
+
113
+ > **🔒 Phone features are NOT served by the Space.** The Twilio call and QR phone-capture run
114
+ > on a **tunneled local machine** (`FF_PUBLIC_BASE_URL` = ngrok/cloudflared URL), because
115
+ > third-party send creds (Twilio) can't live on a public Space (ADR-0005). Modal serves the
116
+ > _models_; the phone _capture paths_ are the local-demo + video story.
117
+
118
+ ## Finalize & Send (S10)
119
+
120
+ **Finalize & Send** delivers a finished estimate to the customer by **SMS** (Twilio MMS —
121
+ the PDF attached by URL) or **email** (SendGrid — the PDF attached inline). It has the same
122
+ honest, env-gated framing as the models:
123
+
124
+ - **Real send is opt-in and local-only.** Set `FF_SEND_ENABLED=1` plus the provider creds
125
+ and the message is actually transmitted. The providers (`twilio`, `sendgrid`) are an
126
+ optional extra — `pip install -e ".[send]"` — deliberately **not** in the Space
127
+ `requirements.txt` (third-party API creds can't live on a public Space — ADR-0005).
128
+ - **The public Space drafts only.** With `FF_SEND_ENABLED` unset, `/api/send_estimate`
129
+ returns `{status: "drafted", transmitted: false}` and the UI shows a "Draft ready —
130
+ nothing was transmitted from this hosted demo" card. It never claims a send it didn't do.
131
+
132
+ **Email has two backends — whichever is configured wins, Gmail first.** Gmail SMTP is the
133
+ simplest (stdlib `smtplib`, no extra dep, no sender-verification step — just a Google
134
+ [App Password](https://myaccount.google.com/apppasswords)); SendGrid is the fallback.
135
+
136
+ Env vars for the real path:
137
+
138
+ | Var | For | Purpose |
139
+ | ------------------------------------------ | --------- | ----------------------------------------------------- |
140
+ | `FF_SEND_ENABLED=1` | both | master gate out of draft-only mode |
141
+ | `TWILIO_ACCOUNT_SID` / `TWILIO_AUTH_TOKEN` | SMS | Twilio auth |
142
+ | `FF_SEND_FROM` | SMS | the Twilio sending number |
143
+ | `GMAIL_ADDRESS` / `GMAIL_APP_PASSWORD` | email (1) | Gmail SMTP — preferred; sends from your Gmail address |
144
+ | `SENDGRID_API_KEY` | email (2) | SendGrid auth (fallback if no Gmail creds) |
145
+ | `FF_SEND_FROM_EMAIL` | email (2) | the SendGrid verified sender address |
146
+
147
+ Facts-from-Tools holds: send introduces no numbers — the PDF and the summary line both go
148
+ through `recalc_estimate`, the same server-authoritative totals the PDF/JSON already show.
149
+ SMS needs a public PDF URL (MMS attaches by URL); the server mints one at
150
+ `/api/estimate_pdf/{token}`.
151
+
152
+ ## Saved Estimates (ADR-0013)
153
+
154
+ Estimates persist per **Account** (one fixed demo account, `account_id="demo"`, no auth).
155
+ A finished forge **auto-saves**; **Save** persists mid-draft; edits **update in place**;
156
+ **Discard** deletes. **My Estimates** lists them (newest first) and reopens a frozen
157
+ snapshot — existing lines never silently re-price; only newly-added lines hit the live
158
+ catalog. Each saved estimate carries a **Refinement Thread**: the post-forge chat turns,
159
+ stored **sanitized** (intents/operations, never dollar figures), so resuming the chat can
160
+ never feed a stale number back to the model — Facts-from-Tools holds on the resume path.
161
+ Long threads are kept in-context by **deterministic compaction** done in code, not by a
162
+ model summary. JSON-on-disk behind a swappable `EstimateStore`; durable locally, per-session
163
+ on the Space (`FF_ESTIMATE_STORE`).
164
+
165
+ ## Phone capture (two inbound paths)
166
+
167
+ Both reuse the same pipeline + Facts-from-Tools; both are real on a tunneled local machine
168
+ (`FF_PUBLIC_BASE_URL` = the ngrok/cloudflared URL), honestly framed everywhere else.
169
+
170
+ - **Call a number (S12)** — a Twilio Voice webhook. The caller describes the job; Quillwright
171
+ transcribes the recording (Audio role — same resolution as the mic button), forges an
172
+ estimate, saves it as a **draft** (a human approves later), reads the spoken total back on
173
+ the call, and texts the PDF via the same SMS path as Finalize & Send. Webhooks:
174
+ `POST /api/voice/incoming` (greeting + `<Record>`) → `POST /api/voice/recording`.
175
+ - **Scan a QR (phone capture)** — the desktop shows a QR (tunnel URL + a pairing code). The
176
+ phone opens a dedicated mobile capture page (`/m/<code>`), takes a photo and/or a voice
177
+ note, and the **desktop forges it live on screen**. QR via the optional `[capture]` extra
178
+ (segno) — local/tunnel only, not on the Space.
179
+
180
  ## Test
181
 
182
  ```
183
  pytest -v
184
+ ruff check . && ruff format --check . # Python lint/format
185
+ npx prettier --check --ignore-unknown "quillwright/web/**/*" # web lint/format
186
+ python scripts/check_deps_sync.py # pyproject ↔ requirements.txt
187
 
188
  # brain accuracy against the eval set (needs Ollama + FF_REAL_MODELS=1)
189
  FF_REAL_MODELS=1 PYTHONPATH=. python scripts/run_brain_eval.py
190
  ```
191
 
192
+ CI (`.github/workflows/ci.yml`) runs the same gate — the dependency-sync check,
193
+ ruff lint/format, pytest, and the web prettier check. It is **manual-only** (to
194
+ conserve Actions minutes): trigger it from the Actions tab or `gh workflow run ci.yml`.
195
+
196
  Models resolve per role via `quillwright/resolver.py` (stub ↔ Ollama). Pricing is clearly-labeled sample data.
data/recall_evalset.json ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_note": "Recall eval for ADR-0003. Corpus = seeded past HVAC runs; queries point at the one 'gold' run a tech would want recalled. Several queries use words that DON'T appear literally in the gold run (coolant->refrigerant, AC unit->condenser) — those are where keyword recall fails and semantic recall should win. SAMPLE data.",
3
+ "corpus": [
4
+ {
5
+ "id": 1,
6
+ "transcript": "replaced the dual run capacitor on the rooftop unit",
7
+ "line_items": ["Dual run capacitor", "Labor"]
8
+ },
9
+ {
10
+ "id": 2,
11
+ "transcript": "topped up the refrigerant after finding a slow leak",
12
+ "line_items": ["R-410A refrigerant", "Labor"]
13
+ },
14
+ {
15
+ "id": 3,
16
+ "transcript": "swapped a burnt compressor contactor",
17
+ "line_items": ["Compressor contactor", "Labor"]
18
+ },
19
+ {
20
+ "id": 4,
21
+ "transcript": "condenser fan motor seized, installed a new one",
22
+ "line_items": ["Condenser fan motor", "Labor"]
23
+ },
24
+ {
25
+ "id": 5,
26
+ "transcript": "cleared a clogged condensate drain line",
27
+ "line_items": ["Drain cleaning", "Labor"]
28
+ },
29
+ {
30
+ "id": 6,
31
+ "transcript": "annual maintenance, replaced the air filter and checked charge",
32
+ "line_items": ["Air filter", "Labor"]
33
+ },
34
+ {
35
+ "id": 7,
36
+ "transcript": "thermostat reading wrong, installed a new programmable stat",
37
+ "line_items": ["Thermostat", "Labor"]
38
+ },
39
+ {
40
+ "id": 8,
41
+ "transcript": "blower motor was noisy, replaced bearings and belt",
42
+ "line_items": ["Blower motor", "Labor"]
43
+ },
44
+ {
45
+ "id": 9,
46
+ "transcript": "low on coolant, recovered and recharged the system",
47
+ "line_items": ["R-410A refrigerant", "Labor"]
48
+ },
49
+ {
50
+ "id": 10,
51
+ "transcript": "hard start kit added to a struggling compressor",
52
+ "line_items": ["Hard start kit", "Labor"]
53
+ },
54
+ {
55
+ "id": 11,
56
+ "transcript": "evaporator coil frozen over, thawed and fixed airflow",
57
+ "line_items": ["Coil cleaning", "Labor"]
58
+ },
59
+ {
60
+ "id": 12,
61
+ "transcript": "replaced a failed start capacitor on the furnace",
62
+ "line_items": ["Start capacitor", "Labor"]
63
+ },
64
+ {
65
+ "id": 13,
66
+ "transcript": "ductwork had a disconnected joint, resealed it",
67
+ "line_items": ["Duct sealing", "Labor"]
68
+ },
69
+ {
70
+ "id": 14,
71
+ "transcript": "gas furnace igniter cracked, installed a new hot surface igniter",
72
+ "line_items": ["Hot surface igniter", "Labor"]
73
+ },
74
+ {
75
+ "id": 15,
76
+ "transcript": "capacitor and contactor both replaced on an old AC unit",
77
+ "line_items": ["Dual run capacitor", "Compressor contactor", "Labor"]
78
+ },
79
+ {
80
+ "id": 16,
81
+ "transcript": "heat pump reversing valve stuck, replaced the valve",
82
+ "line_items": ["Reversing valve", "Labor"]
83
+ },
84
+ {
85
+ "id": 17,
86
+ "transcript": "outdoor disconnect was corroded, replaced the whip and breaker",
87
+ "line_items": ["Disconnect", "Labor"]
88
+ },
89
+ {
90
+ "id": 18,
91
+ "transcript": "mini split indoor head leaking, cleared the drain and pump",
92
+ "line_items": ["Condensate pump", "Labor"]
93
+ }
94
+ ],
95
+ "queries": [
96
+ {
97
+ "query": "coolant recharge",
98
+ "gold_id": 9,
99
+ "note": "synonym: 'coolant' never appears literally — gold run says 'refrigerant'. keyword should miss."
100
+ },
101
+ {
102
+ "query": "AC unit fan not spinning",
103
+ "gold_id": 4,
104
+ "note": "synonym: 'fan motor' is 'condenser fan motor'; 'AC unit' not literal in gold."
105
+ },
106
+ {
107
+ "query": "run capacitor replacement",
108
+ "gold_id": 1,
109
+ "note": "keyword-friendly: 'capacitor' is literal."
110
+ },
111
+ {
112
+ "query": "contactor burnt out",
113
+ "gold_id": 3,
114
+ "note": "keyword-friendly: 'contactor' literal."
115
+ },
116
+ { "query": "clogged drain", "gold_id": 5, "note": "keyword-friendly: 'drain' literal." },
117
+ {
118
+ "query": "system low on charge",
119
+ "gold_id": 2,
120
+ "note": "semantic: 'charge'/'low' map to the refrigerant leak run, not literal."
121
+ },
122
+ {
123
+ "query": "furnace won't ignite",
124
+ "gold_id": 14,
125
+ "note": "semantic: 'ignite' vs 'igniter'; furnace literal."
126
+ },
127
+ {
128
+ "query": "frozen evaporator",
129
+ "gold_id": 11,
130
+ "note": "keyword-friendly: 'evaporator'/'frozen' literal."
131
+ }
132
+ ]
133
+ }
data/sample_inventory.json ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_note": "SAMPLE inventory for the read-only Parts Catalog page (ADR-0010). Stock levels are illustrative; prices mirror data/sample_catalog.json. A part is 'low' when stock <= reorder_at. Live decrement on estimate finalize is an explicit stretch, NOT built.",
3
+ "parts": [
4
+ {
5
+ "key": "capacitor",
6
+ "description": "Dual run capacitor",
7
+ "category": "HVAC",
8
+ "unit": "ea",
9
+ "stock": 42,
10
+ "reorder_at": 15
11
+ },
12
+ {
13
+ "key": "contactor",
14
+ "description": "Compressor contactor",
15
+ "category": "HVAC",
16
+ "unit": "ea",
17
+ "stock": 9,
18
+ "reorder_at": 12
19
+ },
20
+ {
21
+ "key": "refrigerant_r410a",
22
+ "description": "R-410A refrigerant",
23
+ "category": "HVAC",
24
+ "unit": "lb",
25
+ "stock": 6,
26
+ "reorder_at": 20
27
+ },
28
+ {
29
+ "key": "labor",
30
+ "description": "Labor",
31
+ "category": "Service",
32
+ "unit": "hr",
33
+ "stock": 0,
34
+ "reorder_at": 0
35
+ },
36
+ {
37
+ "key": "condenser_fan_motor",
38
+ "description": "Condenser fan motor",
39
+ "category": "HVAC",
40
+ "unit": "ea",
41
+ "stock": 5,
42
+ "reorder_at": 3
43
+ },
44
+ {
45
+ "key": "thermostat",
46
+ "description": "Programmable thermostat",
47
+ "category": "Controls",
48
+ "unit": "ea",
49
+ "stock": 18,
50
+ "reorder_at": 8
51
+ },
52
+ {
53
+ "key": "air_filter",
54
+ "description": "Pleated air filter",
55
+ "category": "HVAC",
56
+ "unit": "ea",
57
+ "stock": 120,
58
+ "reorder_at": 40
59
+ },
60
+ {
61
+ "key": "hard_start_kit",
62
+ "description": "Hard start kit",
63
+ "category": "HVAC",
64
+ "unit": "ea",
65
+ "stock": 4,
66
+ "reorder_at": 6
67
+ }
68
+ ]
69
+ }
package-lock.json CHANGED
@@ -1,10 +1,10 @@
1
  {
2
- "name": "fieldforge-web",
3
  "lockfileVersion": 3,
4
  "requires": true,
5
  "packages": {
6
  "": {
7
- "name": "fieldforge-web",
8
  "devDependencies": {
9
  "prettier": "^3.8.3"
10
  }
 
1
  {
2
+ "name": "quillwright-web",
3
  "lockfileVersion": 3,
4
  "requires": true,
5
  "packages": {
6
  "": {
7
+ "name": "quillwright-web",
8
  "devDependencies": {
9
  "prettier": "^3.8.3"
10
  }
package.json CHANGED
@@ -1 +1,7 @@
1
- {"name":"fieldforge-web","private":true,"devDependencies":{"prettier":"^3.8.3"}}
 
 
 
 
 
 
 
1
+ {
2
+ "name": "quillwright-web",
3
+ "private": true,
4
+ "devDependencies": {
5
+ "prettier": "^3.8.3"
6
+ }
7
+ }
pyproject.toml CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  [project]
2
  name = "quillwright"
3
  version = "0.1.0"
@@ -9,10 +13,31 @@ dependencies = [
9
  "pydantic>=2.7",
10
  "reportlab>=4.2",
11
  "uvicorn>=0.30",
 
12
  ]
13
 
14
  [project.optional-dependencies]
15
  dev = ["pytest>=8.0", "ruff>=0.15"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  [tool.pytest.ini_options]
18
  testpaths = ["tests"]
 
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
  [project]
6
  name = "quillwright"
7
  version = "0.1.0"
 
13
  "pydantic>=2.7",
14
  "reportlab>=4.2",
15
  "uvicorn>=0.30",
16
+ "requests>=2.31",
17
  ]
18
 
19
  [project.optional-dependencies]
20
  dev = ["pytest>=8.0", "ruff>=0.15"]
21
+ # Semantic Recall embedder (ADR-0003): heavy (~2GB torch). Opt-in — NOT in the Space
22
+ # requirements.txt (stub mode has no torch). Install with: pip install -e ".[embed]"
23
+ embed = ["sentence-transformers>=3.0", "numpy>=1.26"]
24
+ # Spoken voice note via Cohere Transcribe (ADR-0009): on-device STT. Opt-in, heavy.
25
+ # Gated model — needs HF access + token. Install with: pip install -e ".[audio]"
26
+ audio = ["transformers>=5.4", "torch", "accelerate", "soundfile", "librosa", "sentencepiece", "protobuf"]
27
+ # Finalize & Send (S10): real SMS (Twilio) + email (SendGrid). Third-party APIs —
28
+ # deliberately NOT in the Space requirements.txt (the Space drafts only; creds can't
29
+ # live on a public Space — ADR-0005). Local/demo path only. Install: pip install -e ".[send]"
30
+ send = ["twilio>=9.0", "sendgrid>=6.11"]
31
+ # QR phone-capture (Tier 3): segno is a tiny pure-Python QR encoder, only needed on the
32
+ # local/tunnel demo path (the Space has no tunnel to pair against). Opt-in, lazy-imported,
33
+ # NOT in the Space requirements. Install with: pip install -e ".[capture]"
34
+ capture = ["segno>=1.6"]
35
+
36
+ # Flat layout with sibling dirs (data/, node_modules/) — scope discovery to the
37
+ # quillwright package (and its subpackages) so setuptools auto-discovery doesn't
38
+ # error on "multiple top-level packages".
39
+ [tool.setuptools.packages.find]
40
+ include = ["quillwright*"]
41
 
42
  [tool.pytest.ini_options]
43
  testpaths = ["tests"]
quillwright/agent.py CHANGED
@@ -1,4 +1,5 @@
1
  from typing import TypedDict, Optional
 
2
  from langgraph.graph import StateGraph, START, END
3
  from langgraph.types import interrupt
4
  from quillwright.models import Capture, Observation, LineItem, Estimate, TraceStep
@@ -67,12 +68,30 @@ def build_agent(perception_model: Model, catalog: Catalog, checkpointer, brain_m
67
  obs_text = ", ".join(ob.text for ob in state["observations"])
68
  priced_extra: list[LineItem] = []
69
  while True:
70
- items, brain_trace, pause = run_brain(
71
- brain_model,
72
- catalog,
73
- observations_text=obs_text,
74
- transcript=state["capture"].transcript,
75
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  if pause is None:
77
  trace = state["trace"] + brain_trace
78
  return {
 
1
  from typing import TypedDict, Optional
2
+ from langgraph.errors import GraphInterrupt
3
  from langgraph.graph import StateGraph, START, END
4
  from langgraph.types import interrupt
5
  from quillwright.models import Capture, Observation, LineItem, Estimate, TraceStep
 
68
  obs_text = ", ".join(ob.text for ob in state["observations"])
69
  priced_extra: list[LineItem] = []
70
  while True:
71
+ try:
72
+ items, brain_trace, pause = run_brain(
73
+ brain_model,
74
+ catalog,
75
+ observations_text=obs_text,
76
+ transcript=state["capture"].transcript,
77
+ )
78
+ except GraphInterrupt:
79
+ raise # an Agent Pause is control flow, not a failure — let it propagate
80
+ except Exception as exc: # noqa: BLE001 — brain/model failure: degrade, don't crash
81
+ # The LLM brain is unavailable (e.g. Ollama 500). Fall back to the
82
+ # deterministic catalog pricer so the forge still produces an estimate
83
+ # instead of crashing the stream. Facts-from-Tools still holds.
84
+ print(f"[quillwright] brain failed ({exc}); falling back to deterministic pricing.")
85
+ out = deterministic_price(state)
86
+ out["trace"] = out["trace"] + [
87
+ TraceStep(
88
+ action="price",
89
+ model="fallback",
90
+ detail="Brain unavailable — priced from the catalog directly.",
91
+ status="ok",
92
+ )
93
+ ]
94
+ return out
95
  if pause is None:
96
  trace = state["trace"] + brain_trace
97
  return {
quillwright/api/chat.py ADDED
@@ -0,0 +1,551 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Conversational refinement of the current estimate — talk to the Digital
2
+ Apprentice about the draft ("add a contactor", "change labor to 3 hours",
3
+ "drop the refrigerant").
4
+
5
+ It is the SAME supervised agent, just conversational. Two paths share ONE set of
6
+ operations (add/remove/change), so Facts-from-Tools (ADR-0004) holds either way —
7
+ the catalog supplies every price; neither the keywords nor the model invent a number:
8
+
9
+ - FF_REAL_MODELS=1 -> Nemotron (tool-calling) picks the operation + item, the
10
+ deterministic ops below execute it (catalog owns the price).
11
+ - otherwise -> a keyword intent parser picks the operation (so the hosted
12
+ stub Space + tests run with zero models).
13
+
14
+ Totals are always recomputed server-authoritatively via recalc_estimate.
15
+ """
16
+
17
+ import os
18
+ import re
19
+
20
+ from quillwright.api.recalc import recalc_estimate
21
+ from quillwright.catalog import Catalog
22
+ from quillwright.thread import append_turn, compact
23
+
24
+ CATALOG = Catalog.from_file("data/sample_catalog.json")
25
+ REAL_MODELS = os.environ.get("FF_REAL_MODELS") == "1"
26
+
27
+ _NUM_WORDS = {
28
+ "a": 1,
29
+ "an": 1,
30
+ "one": 1,
31
+ "two": 2,
32
+ "three": 3,
33
+ "four": 4,
34
+ "five": 5,
35
+ "six": 6,
36
+ "half": 0.5,
37
+ "both": 2,
38
+ "pair": 2,
39
+ }
40
+
41
+
42
+ def _to_qty(text: str) -> float | None:
43
+ """First number-like token in `text` -> a quantity, or None."""
44
+ m = re.search(r"\d+(?:\.\d+)?", text)
45
+ if m:
46
+ return float(m.group())
47
+ for word, val in _NUM_WORDS.items():
48
+ if re.search(rf"\b{word}\b", text):
49
+ return val
50
+ return None
51
+
52
+
53
+ def _to_dollar_amount(text: str) -> float | None:
54
+ """A user-stated price -> float, or None. Matches an explicit `$30` OR a spoken
55
+ `30 dollars` / `30 bucks` (voice transcripts have no `$`). A bare number with no
56
+ money cue is NOT treated as a price (it stays a possible quantity)."""
57
+ m = re.search(r"\$\s*(\d+(?:\.\d+)?)", text)
58
+ if m:
59
+ return float(m.group(1))
60
+ m = re.search(r"(\d+(?:\.\d+)?)\s*(?:dollars?|bucks)\b", text)
61
+ return float(m.group(1)) if m else None
62
+
63
+
64
+ def _finish(
65
+ rows: list[dict],
66
+ tax_rate: float,
67
+ reply: str,
68
+ needs_price: bool = False,
69
+ changed: str | None = None,
70
+ thread: list[dict] | None = None,
71
+ message: str = "",
72
+ op: str = "",
73
+ pending: dict | None = None,
74
+ ) -> dict:
75
+ est = recalc_estimate(rows, job_title="Estimate", tax_rate=tax_rate)
76
+ # `changed` names the line whose rate just changed, so the UI can pulse that cell.
77
+ # The Refinement Thread (ADR-0013) records the human message + a dollar-free op,
78
+ # so resuming can never feed a stale number back to the model (Facts-from-Tools).
79
+ new_thread = append_turn(thread or [], message=message, op=op) if op else (thread or [])
80
+ # `pending` carries a rate change awaiting a scope answer ("this estimate"/"the catalog")
81
+ # to the next turn — the user's stated number, deferred one turn, not the model's.
82
+ return {
83
+ "estimate": est,
84
+ "reply": reply,
85
+ "needs_price": needs_price,
86
+ "changed": changed,
87
+ "thread": new_thread,
88
+ "pending": pending,
89
+ }
90
+
91
+
92
+ def _find_row(rows: list[dict], text: str) -> int | None:
93
+ """Index of the row whose description best matches words in `text`."""
94
+ words = set(re.findall(r"[a-z0-9]+", text.lower()))
95
+ best_i, best_overlap = None, 0
96
+ for i, r in enumerate(rows):
97
+ desc_words = set(re.findall(r"[a-z0-9]+", r["description"].lower()))
98
+ overlap = len(words & desc_words)
99
+ if overlap > best_overlap:
100
+ best_i, best_overlap = i, overlap
101
+ return best_i if best_overlap else None
102
+
103
+
104
+ # --- The shared operations. Each mutates `rows` in place and returns a reply dict
105
+ # fragment {"reply": str, "needs_price"?: bool}. Both paths call these, so the
106
+ # catalog-owns-the-price guarantee lives in exactly one place. ---
107
+
108
+
109
+ def _op_add(rows: list[dict], item: str, quantity: float | None) -> dict:
110
+ priced = CATALOG.lookup(item)
111
+ if not priced:
112
+ return {
113
+ "reply": (
114
+ "I couldn't find that part in the catalog, so I won't guess a price. "
115
+ "Add it manually with a rate and I'll keep the math straight."
116
+ ),
117
+ "needs_price": True,
118
+ "op": "tried to add an unknown part",
119
+ }
120
+ qty = quantity if (quantity and quantity > 0) else 1
121
+ rows.append(
122
+ {
123
+ "description": priced["description"],
124
+ "quantity": qty,
125
+ "unit": priced["unit"],
126
+ "rate": priced["rate"], # Facts-from-Tools: catalog price, never the model.
127
+ }
128
+ )
129
+ return {
130
+ "reply": (
131
+ f"Done — added {qty:g} × {priced['description']} at the catalog rate of "
132
+ f"${priced['rate']:.2f}. I've updated the total."
133
+ ),
134
+ "op": f"added {priced['description']}",
135
+ }
136
+
137
+
138
+ def _op_remove(rows: list[dict], item: str) -> dict:
139
+ i = _find_row(rows, item)
140
+ if i is None:
141
+ return {
142
+ "reply": "I couldn't tell which line to remove — which item did you mean?",
143
+ "op": "tried to remove an unmatched item",
144
+ }
145
+ removed = rows.pop(i)
146
+ return {
147
+ "reply": f"Got it — took {removed['description']} off the estimate and recalculated the total.",
148
+ "op": f"removed {removed['description']}",
149
+ }
150
+
151
+
152
+ def _op_change_qty(rows: list[dict], item: str, quantity: float | None) -> dict:
153
+ i = _find_row(rows, item)
154
+ if i is None or quantity is None:
155
+ return {
156
+ "reply": "Tell me which item and the new quantity — e.g. “change labor to 2 hours”.",
157
+ "op": "asked to change a quantity (unclear)",
158
+ }
159
+ rows[i]["quantity"] = quantity
160
+ return {
161
+ "reply": f"Sure — {rows[i]['description']} is now {quantity:g}. Total's updated.",
162
+ "op": f"set {rows[i]['description']} to {quantity:g}",
163
+ }
164
+
165
+
166
+ def _op_change_rate(rows: list[dict], item: str, rate: float | None, scope: str | None) -> dict:
167
+ """Set a line's rate to a USER-SUPPLIED number (Facts-from-Tools: the number is
168
+ the user's, never the model's). Asks estimate-vs-catalog before applying when the
169
+ scope is unspecified.
170
+
171
+ scope="estimate" -> this row only (price_source="user").
172
+ scope="catalog" -> also writes the in-session catalog so later adds use it.
173
+ """
174
+ i = _find_row(rows, item)
175
+ if i is None or rate is None:
176
+ return {
177
+ "reply": "Tell me which line and the exact rate — e.g. “set the capacitor rate to $30”.",
178
+ "op": "asked to change a rate (unclear)",
179
+ }
180
+ if scope not in ("estimate", "catalog"):
181
+ # Numbers are user-confirmed, but we still ask WHERE it applies before changing.
182
+ # Stash the change as `pending` so the next turn's scope answer can apply it.
183
+ return {
184
+ "reply": (
185
+ f"Should ${rate:.2f} for {rows[i]['description']} apply to just this "
186
+ "estimate, or update the catalog price for future jobs too? "
187
+ "Say “this estimate” or “the catalog”."
188
+ ),
189
+ "op": "asked where a rate applies",
190
+ "pending": {"item": rows[i]["description"], "rate": rate},
191
+ }
192
+ rows[i]["rate"] = rate
193
+ rows[i]["price_source"] = "user" # a human-confirmed price, not catalog/computed
194
+ desc = rows[i]["description"]
195
+ if scope == "catalog":
196
+ # Update the in-session catalog so a later add of the same part picks it up.
197
+ existing = CATALOG.lookup(desc) or {}
198
+ CATALOG.add(
199
+ key=existing.get("key", desc.lower().replace(" ", "_")),
200
+ description=desc,
201
+ unit=rows[i].get("unit", existing.get("unit", "ea")),
202
+ rate=rate,
203
+ )
204
+ where = "this estimate and the catalog"
205
+ else:
206
+ where = "this estimate"
207
+ return {
208
+ "reply": f"Done — {desc} is now ${rate:.2f} for {where}, and the total's updated.",
209
+ "changed": desc,
210
+ # Op is dollar-free by construction (Facts-from-Tools holds in the thread too).
211
+ "op": f"set the rate for {desc} ({where})",
212
+ }
213
+
214
+
215
+ def _answer_about_estimate(rows: list[dict], tax_rate: float) -> str:
216
+ """A spoken-friendly answer to 'what's the total / what's on it' — every number from
217
+ recalc (Facts-from-Tools), never free-generated."""
218
+ est = recalc_estimate(rows, job_title="Estimate", tax_rate=tax_rate)
219
+ items = est["line_items"]
220
+ if not items:
221
+ return "The estimate is empty right now — tell me what to add."
222
+ n = len(items)
223
+ listed = ", ".join(f"{li['quantity']:g} {li['description'].lower()}" for li in items)
224
+ return (
225
+ f"You've got {n} item{'s' if n != 1 else ''}: {listed}. "
226
+ f"The total comes to ${est['total']:.2f}."
227
+ )
228
+
229
+
230
+ # --- LLM tool surface: the model only PICKS the operation + item (+ quantity);
231
+ # execution + pricing stay in the deterministic ops above. ---
232
+
233
+ CHAT_TOOLS = [
234
+ {
235
+ "type": "function",
236
+ "function": {
237
+ "name": "add_item",
238
+ "description": "Add a part or labor to the estimate. The catalog price is applied automatically.",
239
+ "parameters": {
240
+ "type": "object",
241
+ "properties": {
242
+ "item": {"type": "string", "description": "part or labor name"},
243
+ "quantity": {"type": "number", "description": "units/hours (default 1)"},
244
+ },
245
+ "required": ["item"],
246
+ },
247
+ },
248
+ },
249
+ {
250
+ "type": "function",
251
+ "function": {
252
+ "name": "remove_item",
253
+ "description": "Remove a line item from the estimate.",
254
+ "parameters": {
255
+ "type": "object",
256
+ "properties": {"item": {"type": "string", "description": "the item to remove"}},
257
+ "required": ["item"],
258
+ },
259
+ },
260
+ },
261
+ {
262
+ "type": "function",
263
+ "function": {
264
+ "name": "change_quantity",
265
+ "description": "Change the quantity (units or hours) of an existing line item.",
266
+ "parameters": {
267
+ "type": "object",
268
+ "properties": {
269
+ "item": {"type": "string", "description": "the item to adjust"},
270
+ "quantity": {"type": "number", "description": "the new quantity"},
271
+ },
272
+ "required": ["item", "quantity"],
273
+ },
274
+ },
275
+ },
276
+ {
277
+ "type": "function",
278
+ "function": {
279
+ "name": "change_rate",
280
+ "description": (
281
+ "Set the rate (unit price) of an existing line item to a price the USER "
282
+ "EXPLICITLY STATED. Only call this when the user gave an exact number — "
283
+ "never choose or estimate a price yourself. `scope` says where it applies: "
284
+ "'estimate' (this estimate only) or 'catalog' (also the catalog, for future "
285
+ "jobs). If the user did not say which, OMIT scope — the assistant will ask."
286
+ ),
287
+ "parameters": {
288
+ "type": "object",
289
+ "properties": {
290
+ "item": {"type": "string", "description": "the item whose rate to set"},
291
+ "rate": {
292
+ "type": "number",
293
+ "description": "the exact unit price the user stated (e.g. 30 for $30)",
294
+ },
295
+ "scope": {
296
+ "type": "string",
297
+ "enum": ["estimate", "catalog"],
298
+ "description": "'estimate' = this estimate only; 'catalog' = also "
299
+ "the catalog. Omit if the user didn't specify.",
300
+ },
301
+ },
302
+ "required": ["item", "rate"],
303
+ },
304
+ },
305
+ },
306
+ ]
307
+
308
+ _CHAT_SYSTEM = (
309
+ "You are a field-service estimator's assistant. The user wants to refine the current "
310
+ "estimate. Decide the single edit they're asking for and call ONE tool: add_item, "
311
+ "remove_item, change_quantity, or change_rate. "
312
+ "ALWAYS prefer calling a tool over replying in plain text. The user's intent is often "
313
+ "phrased conversationally or buried mid-sentence — extract it and act. Map the request "
314
+ "to the closest tool even when the wording is indirect. Examples:\n"
315
+ "- 'it actually took more than one capacitor, could you make it 2?' → change_quantity("
316
+ "item='capacitor', quantity=2)\n"
317
+ "- 'I ended up using two contactors' → change_quantity(item='contactor', quantity=2)\n"
318
+ "- 'throw in a refrigerant too' / 'I also needed refrigerant' → add_item(item='refrigerant')\n"
319
+ "- 'scrap the labor line' / 'we didn't end up doing labor' → remove_item(item='labor')\n"
320
+ "- 'bump labor to three hours' → change_quantity(item='labor', quantity=3)\n"
321
+ "Never invent prices. For add_item the catalog supplies the price. "
322
+ "change_rate is ONLY for a price the user STATED EXACTLY (e.g. “make it $30”): pass that "
323
+ "exact number as `rate`. If the user asks to change a price WITHOUT giving a number "
324
+ "(e.g. “make it cheaper”), do NOT call change_rate and do NOT pick a number — answer in "
325
+ "plain text asking what rate they want. When you do call change_rate, include `scope` "
326
+ "ONLY if the user said whether it applies to just this estimate or the catalog; if they "
327
+ "did not say, omit `scope` and the assistant will ask. "
328
+ "Only reply in plain text WITHOUT a tool when they're genuinely just asking a question "
329
+ "(e.g. 'what's the total?') or when you truly cannot map the request to any edit."
330
+ )
331
+
332
+
333
+ def _apply_model_call(name: str, args: dict, rows: list[dict]) -> dict:
334
+ item = str(args.get("item", "")).strip()
335
+ qty = args.get("quantity")
336
+ qty = float(qty) if isinstance(qty, (int, float)) else _to_qty(item)
337
+ if name == "add_item":
338
+ return _op_add(rows, item, qty)
339
+ if name == "remove_item":
340
+ return _op_remove(rows, item)
341
+ if name == "change_quantity":
342
+ return _op_change_qty(rows, item, qty)
343
+ if name == "change_rate":
344
+ rate = args.get("rate")
345
+ rate = float(rate) if isinstance(rate, (int, float)) else None
346
+ scope = args.get("scope")
347
+ return _op_change_rate(rows, item, rate, scope)
348
+ return {"reply": "I'm not sure how to do that — try add, remove, or change a quantity or rate."}
349
+
350
+
351
+ def _model_chat(message: str, rows: list[dict], tax_rate: float, model, thread: list[dict]) -> dict:
352
+ """Let the tool-calling model pick the edit; execute it through the shared ops.
353
+
354
+ The compacted, sanitized thread (ops only, no dollars — ADR-0013) is replayed for
355
+ reference resolution ("make *it* 2 hours"); numbers always come from the current rows.
356
+ """
357
+ rows_summary = (
358
+ ", ".join(f"{r['description']} (qty {r['quantity']:g})" for r in rows) or "(empty)"
359
+ )
360
+ history = compact(thread)
361
+ user = (
362
+ f"Earlier edits:\n{history}\n\n" if history else ""
363
+ ) + f"Current estimate: {rows_summary}\nRequest: {message}"
364
+ messages = [
365
+ {"role": "system", "content": _CHAT_SYSTEM},
366
+ {"role": "user", "content": user},
367
+ ]
368
+ msg = model.chat(messages, CHAT_TOOLS)
369
+ tool_calls = msg.get("tool_calls") or []
370
+ if not tool_calls:
371
+ # No edit — the model answered a question. Estimate stays untouched. If it's a
372
+ # total/contents question, answer it deterministically (the number must come from
373
+ # recalc, never the model — Facts-from-Tools), else relay the model's plain text.
374
+ text = (msg.get("content") or "").strip()
375
+ if re.search(
376
+ r"\b(total|how much|what'?s on|what is on|whats on|breakdown)\b", message.lower()
377
+ ):
378
+ text = _answer_about_estimate(rows, tax_rate)
379
+ return _finish(
380
+ rows,
381
+ tax_rate,
382
+ text or "Let me know what you'd like to change.",
383
+ thread=thread,
384
+ message=message,
385
+ op="asked a question",
386
+ )
387
+
388
+ fn = tool_calls[0].get("function", {})
389
+ result = _apply_model_call(fn.get("name", ""), fn.get("arguments", {}) or {}, rows)
390
+ return _finish(
391
+ rows,
392
+ tax_rate,
393
+ result["reply"],
394
+ needs_price=result.get("needs_price", False),
395
+ changed=result.get("changed"),
396
+ thread=thread,
397
+ message=message,
398
+ op=result.get("op", ""),
399
+ pending=result.get("pending"),
400
+ )
401
+
402
+
403
+ def _keyword_chat(message: str, rows: list[dict], tax_rate: float, thread: list[dict]) -> dict:
404
+ """Zero-model fallback: a keyword intent parser drives the same shared ops."""
405
+ msg = message.strip().lower()
406
+ if not msg:
407
+ return _finish(
408
+ rows,
409
+ tax_rate,
410
+ "Tell me what to change — add a part, drop one, or adjust a quantity.",
411
+ thread=thread,
412
+ )
413
+
414
+ # A read-only question about the estimate ("what's the total", "what's on it",
415
+ # "how much is it") — answer it instead of falling through to generic help. Checked
416
+ # before the edit verbs, but only when no edit verb is present so "add ..." still adds.
417
+ is_question = re.search(r"\b(total|how much|what'?s on|what is on|whats on|breakdown)\b", msg)
418
+ has_edit_verb = re.search(r"\b(add|remove|delete|drop|set|change|make|update|include)\b", msg)
419
+ if is_question and not has_edit_verb:
420
+ return _finish(
421
+ rows,
422
+ tax_rate,
423
+ _answer_about_estimate(rows, tax_rate),
424
+ thread=thread,
425
+ message=message,
426
+ op="asked about the estimate",
427
+ )
428
+
429
+ if re.search(r"\b(remove|delete|drop|take off|get rid of)\b", msg):
430
+ result = _op_remove(rows, msg)
431
+ return _finish(
432
+ rows, tax_rate, result["reply"], thread=thread, message=message, op=result.get("op", "")
433
+ )
434
+
435
+ # An explicit dollar amount ("set the capacitor rate to $30") is a user-confirmed
436
+ # rate change. Checked BEFORE the quantity branch so the "$30" isn't read as a qty.
437
+ # The keyword path can't hold a follow-up turn, so it takes the conservative
438
+ # estimate-only scope (the model path is the one that asks catalog-vs-estimate).
439
+ # _to_dollar_amount only returns a value when a money cue is present ($, "dollars",
440
+ # "bucks"), so its non-None result is itself the signal this is a rate, not a quantity.
441
+ rate_amount = _to_dollar_amount(msg)
442
+ if rate_amount is not None:
443
+ i = _find_row(rows, msg)
444
+ if i is not None:
445
+ result = _op_change_rate(rows, rows[i]["description"], rate_amount, scope="estimate")
446
+ return _finish(
447
+ rows,
448
+ tax_rate,
449
+ result["reply"],
450
+ changed=result.get("changed"),
451
+ thread=thread,
452
+ message=message,
453
+ op=result.get("op", ""),
454
+ )
455
+
456
+ if re.search(r"\b(change|set|make|update)\b", msg) or re.search(
457
+ r"\bto\b.*\b(hour|hr|unit|lb|pound)", msg
458
+ ):
459
+ i = _find_row(rows, msg)
460
+ qty = _to_qty(msg)
461
+ if i is not None and qty is not None:
462
+ result = _op_change_qty(rows, rows[i]["description"], qty)
463
+ return _finish(
464
+ rows,
465
+ tax_rate,
466
+ result["reply"],
467
+ thread=thread,
468
+ message=message,
469
+ op=result.get("op", ""),
470
+ )
471
+
472
+ if re.search(r"\b(add|include|put in|need|another|more)\b", msg):
473
+ result = _op_add(rows, msg, _to_qty(msg))
474
+ return _finish(
475
+ rows,
476
+ tax_rate,
477
+ result["reply"],
478
+ needs_price=result.get("needs_price", False),
479
+ thread=thread,
480
+ message=message,
481
+ op=result.get("op", ""),
482
+ )
483
+
484
+ return _finish(
485
+ rows,
486
+ tax_rate,
487
+ "I can add a part, remove one, or change a quantity — e.g. “add a contactor” or "
488
+ "“change labor to 2 hours”. What would you like to adjust?",
489
+ thread=thread,
490
+ )
491
+
492
+
493
+ def _resolve_brain():
494
+ """Real tool-calling model when enabled (local Ollama or hosted Modal); else None."""
495
+ if REAL_MODELS or os.environ.get("FF_BACKEND") == "modal":
496
+ from quillwright.resolver import brain_resolver
497
+
498
+ return brain_resolver().for_role("brain")
499
+ return None
500
+
501
+
502
+ def _scope_answer(message: str) -> str | None:
503
+ """Map a scope reply to 'estimate'/'catalog', or None if it isn't one."""
504
+ m = message.strip().lower()
505
+ if re.search(r"\bcatalog\b|\bboth\b|future job", m):
506
+ return "catalog"
507
+ if re.search(r"\b(this|just this|estimate only|only this|here|this one)\b", m):
508
+ return "estimate"
509
+ return None
510
+
511
+
512
+ def chat_about_estimate(
513
+ message: str, rows: list[dict], tax_rate: float = 0.13, model=None, thread=None, pending=None
514
+ ) -> dict:
515
+ """Apply a conversational edit to the estimate. Returns
516
+ {estimate, reply, needs_price, changed, thread, pending}.
517
+
518
+ `model` is injectable for tests; in production it's resolved from FF_REAL_MODELS.
519
+ `thread` is the Refinement Thread (ADR-0013): sanitized, dollar-free history.
520
+ `pending` carries a rate change awaiting a scope answer from the previous turn — if it
521
+ is set and this message answers "this estimate"/"the catalog", apply it directly (no
522
+ model), so the two-turn rate change doesn't lose context.
523
+ """
524
+ rows = [dict(r) for r in rows] # don't mutate the caller's list
525
+ thread = list(thread or [])
526
+
527
+ # Resolve a pending rate change first: "the catalog" / "this estimate" applies the
528
+ # number the user stated last turn (Facts-from-Tools — it's the user's, just deferred).
529
+ if pending and pending.get("item") and pending.get("rate") is not None:
530
+ scope = _scope_answer(message)
531
+ if scope is not None:
532
+ result = _op_change_rate(rows, pending["item"], float(pending["rate"]), scope=scope)
533
+ return _finish(
534
+ rows,
535
+ tax_rate,
536
+ result["reply"],
537
+ changed=result.get("changed"),
538
+ thread=thread,
539
+ message=message,
540
+ op=result.get("op", ""),
541
+ )
542
+ # Not a scope answer — fall through to normal handling, dropping the pending change.
543
+
544
+ brain = model if model is not None else _resolve_brain()
545
+ if brain is not None:
546
+ try:
547
+ return _model_chat(message, rows, tax_rate, brain, thread)
548
+ except Exception as exc: # noqa: BLE001 — model down (e.g. Ollama 500): degrade
549
+ # Fall back to the deterministic keyword path so a chat turn never 500s the UI.
550
+ print(f"[quillwright] chat brain failed ({exc}); using keyword fallback.")
551
+ return _keyword_chat(message, rows, tax_rate, thread)
quillwright/api/document.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Document Capture: a handed-over document -> Observations + Proposed Line Items (ADR-0011).
2
+
3
+ Thin adapter over the Extraction role (Nemotron Parse on Modal): file path in, JSON
4
+ the frontend confirm card renders out. The model is injectable for tests. In
5
+ production it resolves to ParseModel when FF_MODAL_PARSE_URL is set; otherwise a
6
+ deterministic demo parse runs the REAL blocks_to_pipeline logic over a canned
7
+ supplier quote, so the stub Space demos the flow with zero models (the same
8
+ honest-scaffolding pattern as _stub_perception in api/estimate.py).
9
+ """
10
+
11
+ import os
12
+
13
+ from quillwright.backends.parse import blocks_to_pipeline
14
+
15
+ # The canned supplier quote the demo "reads" when no Modal Parse endpoint is
16
+ # configured. Mirrors the test fixture in test_parse_backend.py.
17
+ _DEMO_BLOCKS = [
18
+ {"class": "Title", "bbox": [], "text": "ACME HVAC Supply — Quote #1042"},
19
+ {
20
+ "class": "Table",
21
+ "bbox": [],
22
+ "text": (
23
+ "| Item | Qty | Unit Price |\n"
24
+ "| --- | --- | --- |\n"
25
+ "| Dual run capacitor | 2 | $42.50 |\n"
26
+ "| Compressor contactor | 1 | $28.00 |\n"
27
+ "| R-410A refrigerant | 4 | $30.00 |\n"
28
+ ),
29
+ },
30
+ {"class": "Text", "bbox": [], "text": "Net 30 terms. Prices valid 30 days."},
31
+ ]
32
+
33
+
34
+ def _resolve_extraction():
35
+ """Real Nemotron Parse when its Modal URL is configured; else None (demo parse)."""
36
+ if os.environ.get("FF_MODAL_PARSE_URL"):
37
+ from quillwright.resolver import ModelResolver
38
+
39
+ return ModelResolver(mode="best", backend="modal").for_role("extraction")
40
+ return None
41
+
42
+
43
+ def parse_document_capture(path: str, model=None) -> dict:
44
+ """Parse the document at `path`; return {model, observations, proposed_items}.
45
+
46
+ Every price stays *proposed* — the human confirms it in the UI before it becomes
47
+ a LineItem with price_source="document" (Facts-from-Tools, ADR-0004/0011).
48
+ """
49
+ parser = model if model is not None else _resolve_extraction()
50
+ if parser is None:
51
+ observations, proposed = blocks_to_pipeline(_DEMO_BLOCKS)
52
+ name = "parse-stub (demo quote)"
53
+ else:
54
+ observations, proposed = parser.parse_document(path)
55
+ name = parser.name
56
+ return {
57
+ "model": name,
58
+ "observations": [{"kind": o.kind, "text": o.text} for o in observations],
59
+ "proposed_items": [
60
+ {
61
+ "description": p.description,
62
+ "quantity": p.quantity,
63
+ "unit": p.unit,
64
+ "rate": p.rate,
65
+ "source_text": p.source_text,
66
+ }
67
+ for p in proposed
68
+ ],
69
+ }
quillwright/api/estimate.py CHANGED
@@ -12,6 +12,7 @@ from langgraph.types import Command
12
 
13
  from quillwright.agent import build_agent
14
  from quillwright.catalog import Catalog
 
15
  from quillwright.memory import Memory
16
  from quillwright.models import Capture
17
  from quillwright.resolver import ModelResolver, StubModel
@@ -36,6 +37,32 @@ def reset_memory() -> None:
36
  _MEMORY = None
37
 
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  # FF_REAL_MODELS=1 uses real local models via Ollama; otherwise the demo stub.
40
  REAL_MODELS = os.environ.get("FF_REAL_MODELS") == "1"
41
 
@@ -60,16 +87,29 @@ def _stub_perception(transcript: str) -> StubModel:
60
 
61
 
62
  def _perception(transcript: str, has_real_image: bool):
63
- """Real MiniCPM-V via Ollama when enabled AND a real photo exists; else the stub."""
64
- if REAL_MODELS and has_real_image:
65
- return ModelResolver(mode="private", backend="ollama").for_role("perception")
 
 
 
 
 
 
 
66
  return _stub_perception(transcript)
67
 
68
 
69
  def _brain():
70
- """Real Ollama tool-calling brain when FF_REAL_MODELS=1; else None (deterministic path)."""
71
- if REAL_MODELS:
72
- return ModelResolver(mode="private", backend="ollama").for_role("brain")
 
 
 
 
 
 
73
  return None
74
 
75
 
@@ -116,10 +156,22 @@ def forge_estimate(
116
  {"configurable": {"thread_id": "ui"}},
117
  )
118
  est = out.get("estimate")
119
- return {
120
  "trace": _trace_payload(out["trace"]),
121
  "estimate": _estimate_payload(est) if est is not None else None,
122
  }
 
 
 
 
 
 
 
 
 
 
 
 
123
 
124
 
125
  # Active runs by thread_id, so a paused run can be resumed with the same agent + checkpointer.
@@ -172,11 +224,15 @@ def _drive(agent, payload, thread_id: str):
172
  _memory().record_run(
173
  run.get("transcript", ""),
174
  [li.description for li in estimate.line_items],
 
175
  )
176
 
 
 
 
177
  yield {
178
  "type": "estimate",
179
- "estimate": _estimate_payload(estimate) if estimate is not None else None,
180
  }
181
  _RUNS.pop(thread_id, None)
182
 
 
12
 
13
  from quillwright.agent import build_agent
14
  from quillwright.catalog import Catalog
15
+ from quillwright.estimate_store import EstimateStore
16
  from quillwright.memory import Memory
17
  from quillwright.models import Capture
18
  from quillwright.resolver import ModelResolver, StubModel
 
37
  _MEMORY = None
38
 
39
 
40
+ # Per-Account Estimate Store (ADR-0013) — separate from Episodic Memory above.
41
+ # A singleton mirroring _memory(); re-reads its env path after reset_estimate_store().
42
+ _ESTIMATE_STORE: EstimateStore | None = None
43
+
44
+
45
+ def estimate_store() -> EstimateStore:
46
+ global _ESTIMATE_STORE
47
+ if _ESTIMATE_STORE is None:
48
+ _ESTIMATE_STORE = EstimateStore()
49
+ return _ESTIMATE_STORE
50
+
51
+
52
+ def reset_estimate_store() -> None:
53
+ """Drop the in-process store (re-reads env path next use). For tests."""
54
+ global _ESTIMATE_STORE
55
+ _ESTIMATE_STORE = None
56
+
57
+
58
+ def save_estimate_record(rows, job_title, tax_rate, thread, id=None) -> dict:
59
+ """Recalc to authoritative numbers (Facts-from-Tools), then persist the snapshot."""
60
+ from quillwright.api.recalc import recalc_estimate
61
+
62
+ est = recalc_estimate(rows, job_title=job_title, tax_rate=tax_rate)
63
+ return estimate_store().save(estimate=est, thread=thread, id=id)
64
+
65
+
66
  # FF_REAL_MODELS=1 uses real local models via Ollama; otherwise the demo stub.
67
  REAL_MODELS = os.environ.get("FF_REAL_MODELS") == "1"
68
 
 
87
 
88
 
89
  def _perception(transcript: str, has_real_image: bool):
90
+ """The Perception role for a real photo: hosted Omni (Best Stack) when its
91
+ Modal URL is configured, MiniCPM-V via Ollama under FF_REAL_MODELS, else stub."""
92
+ if has_real_image:
93
+ from quillwright.resolver import modal_resolver_if_configured
94
+
95
+ modal = modal_resolver_if_configured("perception")
96
+ if modal is not None:
97
+ return modal.for_role("perception")
98
+ if REAL_MODELS:
99
+ return ModelResolver(mode="private", backend="ollama").for_role("perception")
100
  return _stub_perception(transcript)
101
 
102
 
103
  def _brain():
104
+ """Real tool-calling brain when enabled; else None (deterministic path).
105
+
106
+ Local Ollama (FF_REAL_MODELS=1) or hosted Modal Best-Stack (FF_BACKEND=modal);
107
+ brain_resolver() picks based on env.
108
+ """
109
+ if REAL_MODELS or os.environ.get("FF_BACKEND") == "modal":
110
+ from quillwright.resolver import brain_resolver
111
+
112
+ return brain_resolver().for_role("brain")
113
  return None
114
 
115
 
 
156
  {"configurable": {"thread_id": "ui"}},
157
  )
158
  est = out.get("estimate")
159
+ payload = {
160
  "trace": _trace_payload(out["trace"]),
161
  "estimate": _estimate_payload(est) if est is not None else None,
162
  }
163
+ if payload["estimate"] is not None:
164
+ _autosave(payload["estimate"])
165
+ return payload
166
+
167
+
168
+ def _autosave(estimate: dict) -> None:
169
+ """Auto-save a finished estimate so 'My Estimates' populates (ADR-0013 lifecycle).
170
+ Best-effort: persistence must never fail a forge."""
171
+ try:
172
+ estimate_store().save(estimate=estimate, thread=[])
173
+ except Exception: # noqa: BLE001 — persistence is best-effort
174
+ pass
175
 
176
 
177
  # Active runs by thread_id, so a paused run can be resumed with the same agent + checkpointer.
 
224
  _memory().record_run(
225
  run.get("transcript", ""),
226
  [li.description for li in estimate.line_items],
227
+ total=estimate.total,
228
  )
229
 
230
+ est_payload = _estimate_payload(estimate) if estimate is not None else None
231
+ if est_payload is not None:
232
+ _autosave(est_payload) # ADR-0013: finished forge auto-saves to the store
233
  yield {
234
  "type": "estimate",
235
+ "estimate": est_payload,
236
  }
237
  _RUNS.pop(thread_id, None)
238
 
quillwright/api/export.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Export an estimate as machine-readable JSON — the "no lock-in" counterpart to
2
+ the PDF. Totals go through the same server-authoritative recalc (Facts-from-Tools),
3
+ so the exported numbers match exactly what the customer-facing PDF shows.
4
+ """
5
+
6
+ from quillwright.api.recalc import recalc_estimate
7
+
8
+ DISCLAIMER = "AI-generated draft — review before sending. Sample pricing."
9
+
10
+
11
+ def estimate_to_json_payload(rows: list[dict], job_title: str, tax_rate: float) -> dict:
12
+ """Return a self-describing JSON payload for the (possibly edited) estimate."""
13
+ est = recalc_estimate(rows, job_title=job_title, tax_rate=tax_rate)
14
+ return {
15
+ "format": "quillwright.estimate.v1",
16
+ "disclaimer": DISCLAIMER,
17
+ **est,
18
+ }
quillwright/api/pages.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Read-models for the secondary pages (ADR-0010): Dashboard, Active Jobs, Inventory.
2
+
3
+ Dashboard + Active Jobs aggregate the SAME on-device memory store the agent already
4
+ writes to — so every number on those pages is real, derived from past Runs (no
5
+ invented revenue/technicians/CRM fields). Inventory is a read-only view over a
6
+ seeded JSON joined with the real catalog price; low-stock flags are computed, not
7
+ hardcoded. Live decrement is an explicit stretch and is NOT implemented here.
8
+ """
9
+
10
+ import json
11
+
12
+ from quillwright.catalog import Catalog
13
+ from quillwright.memory import Memory
14
+
15
+ MEMORY_PATH = "/tmp/quillwright_memory.json"
16
+ INVENTORY_PATH = "data/sample_inventory.json"
17
+ CATALOG_PATH = "data/sample_catalog.json"
18
+
19
+
20
+ def _memory() -> Memory:
21
+ # A fresh handle each call so the page always reflects the latest recorded runs.
22
+ return Memory(MEMORY_PATH)
23
+
24
+
25
+ def _items_summary(line_items: list[str], limit: int = 3) -> str:
26
+ shown = line_items[:limit]
27
+ extra = len(line_items) - len(shown)
28
+ text = ", ".join(shown)
29
+ return f"{text} +{extra} more" if extra > 0 else text
30
+
31
+
32
+ def dashboard_data() -> dict:
33
+ """KPI cards + recent activity, aggregated over real past Runs."""
34
+ mem = _memory()
35
+ prof = mem.profile()
36
+ recent = mem.recent(limit=6)
37
+ return {
38
+ "job_count": prof["job_count"],
39
+ "revenue_total": prof["revenue_total"],
40
+ "top_items": prof["common_items"][:5],
41
+ "recent": [
42
+ {
43
+ "id": r["id"],
44
+ "transcript": r["transcript"],
45
+ "items": _items_summary(r["line_items"]),
46
+ "total": r["total"],
47
+ }
48
+ for r in recent
49
+ ],
50
+ }
51
+
52
+
53
+ def jobs_data() -> dict:
54
+ """Every past Run as a table row, newest first."""
55
+ mem = _memory()
56
+ return {
57
+ "jobs": [
58
+ {
59
+ "id": r["id"],
60
+ "transcript": r["transcript"],
61
+ "items": _items_summary(r["line_items"], limit=4),
62
+ "total": r["total"],
63
+ }
64
+ for r in mem.recent()
65
+ ]
66
+ }
67
+
68
+
69
+ def inventory_data() -> dict:
70
+ """Read-only stock view: seeded levels joined with the real catalog price."""
71
+ with open(INVENTORY_PATH) as f:
72
+ seeded = json.load(f)["parts"]
73
+ catalog = Catalog.from_file(CATALOG_PATH)
74
+ parts = []
75
+ for p in seeded:
76
+ cat = catalog.lookup(p["key"])
77
+ rate = cat["rate"] if cat else None
78
+ low = p["reorder_at"] > 0 and p["stock"] <= p["reorder_at"]
79
+ parts.append(
80
+ {
81
+ "description": p["description"],
82
+ "category": p["category"],
83
+ "unit": p["unit"],
84
+ "stock": p["stock"],
85
+ "reorder_at": p["reorder_at"],
86
+ "rate": rate,
87
+ "low": low,
88
+ }
89
+ )
90
+ return {
91
+ "parts": parts,
92
+ "total_skus": len(parts),
93
+ "low_stock_count": sum(1 for p in parts if p["low"]),
94
+ }
quillwright/api/pdf_links.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tokenized PDF link registry (S10).
2
+
3
+ Twilio MMS attaches media by **URL**, not by file upload — so to text a customer
4
+ their estimate PDF we need a public URL Twilio can GET. This registry holds rendered
5
+ PDF bytes in process, keyed by a content-hash token, and the server exposes them at
6
+ ``/api/estimate_pdf/{token}``.
7
+
8
+ The token is a content hash (deterministic, offline-friendly — no uuid/wall-clock,
9
+ matching the rest of the codebase's id scheme), URL-safe, and unguessable enough for
10
+ a demo (the bytes are an AI-draft estimate, not a secret). In-process only: it lives
11
+ for the life of the server, which is all the MMS fetch needs. A durable/expiring
12
+ store is a post-hackathon swap behind this same tiny interface.
13
+ """
14
+
15
+ import hashlib
16
+
17
+ # token -> pdf bytes. Process-local; fine for the local demo path.
18
+ _PDFS: dict[str, bytes] = {}
19
+
20
+
21
+ def register_pdf(pdf_bytes: bytes) -> str:
22
+ """Store the bytes under a content-hash token and return the token."""
23
+ token = hashlib.sha256(pdf_bytes).hexdigest()[:16]
24
+ _PDFS[token] = pdf_bytes
25
+ return token
26
+
27
+
28
+ def get_pdf(token: str) -> bytes | None:
29
+ """Return the bytes for a token, or None if unknown."""
30
+ return _PDFS.get(token)
31
+
32
+
33
+ def public_pdf_url(token: str, base_url: str) -> str:
34
+ """Build the public URL Twilio fetches the PDF from."""
35
+ return f"{base_url.rstrip('/')}/api/estimate_pdf/{token}"
36
+
37
+
38
+ def reset() -> None:
39
+ """Drop all registered PDFs (tests)."""
40
+ _PDFS.clear()
quillwright/api/qr.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """QR code as inline SVG for the phone-capture pairing (Tier 3).
2
+
3
+ ``segno`` is a tiny, pure-Python, zero-dependency QR encoder — but it's only needed on
4
+ the local/tunnel demo path (the hosted Space has no tunnel to pair against), so it lives
5
+ in the optional ``[capture]`` extra and is lazy-imported, the same pattern as the
6
+ ``[send]``/``[embed]``/``[audio]`` extras (kept OUT of the Space requirements, ADR-0005).
7
+
8
+ If segno isn't installed we degrade honestly: an empty string, and the UI shows the
9
+ pairing link as scannable text instead of claiming a QR it can't render.
10
+ """
11
+
12
+ import io
13
+
14
+
15
+ def qr_svg(data: str, scale: int = 5) -> str:
16
+ """Return an inline SVG QR for ``data``, or '' if the encoder isn't installed."""
17
+ try:
18
+ import segno # noqa: PLC0415 — lazy: optional [capture] dep, not in the Space
19
+ except ImportError:
20
+ return ""
21
+ buf = io.BytesIO()
22
+ # xmldecl=False so the SVG embeds cleanly inline in the desktop HTML (no <?xml?> prolog).
23
+ segno.make(data, error="m").save(buf, kind="svg", scale=scale, border=2, xmldecl=False)
24
+ return buf.getvalue().decode()
quillwright/api/recalc.py CHANGED
@@ -5,6 +5,11 @@ human edits; the UI never computes its own authoritative totals.
5
 
6
  from quillwright.models import Estimate, LineItem
7
 
 
 
 
 
 
8
 
9
  def _num(value) -> float:
10
  try:
@@ -13,6 +18,10 @@ def _num(value) -> float:
13
  return 0.0
14
 
15
 
 
 
 
 
16
  def recalc_estimate(rows: list[dict], job_title: str, tax_rate: float) -> dict:
17
  items = [
18
  LineItem(
@@ -20,7 +29,7 @@ def recalc_estimate(rows: list[dict], job_title: str, tax_rate: float) -> dict:
20
  quantity=_num(r.get("quantity", 1)),
21
  unit=str(r.get("unit", "ea")),
22
  rate=_num(r.get("rate", 0)),
23
- price_source="user",
24
  )
25
  for r in rows
26
  ]
@@ -34,6 +43,7 @@ def recalc_estimate(rows: list[dict], job_title: str, tax_rate: float) -> dict:
34
  "unit": li.unit,
35
  "rate": li.rate,
36
  "subtotal": li.subtotal,
 
37
  }
38
  for li in est.line_items
39
  ],
 
5
 
6
  from quillwright.models import Estimate, LineItem
7
 
8
+ # Provenances a client row may carry through a recalc. "document" marks a confirmed
9
+ # Document Capture price (ADR-0011); anything unrecognized falls back to "user" so
10
+ # arbitrary client strings never enter the model.
11
+ _CLIENT_SOURCES = {"document", "user", "catalog", "computed"}
12
+
13
 
14
  def _num(value) -> float:
15
  try:
 
18
  return 0.0
19
 
20
 
21
+ def _source(value) -> str:
22
+ return value if value in _CLIENT_SOURCES else "user"
23
+
24
+
25
  def recalc_estimate(rows: list[dict], job_title: str, tax_rate: float) -> dict:
26
  items = [
27
  LineItem(
 
29
  quantity=_num(r.get("quantity", 1)),
30
  unit=str(r.get("unit", "ea")),
31
  rate=_num(r.get("rate", 0)),
32
+ price_source=_source(r.get("price_source")),
33
  )
34
  for r in rows
35
  ]
 
43
  "unit": li.unit,
44
  "rate": li.rate,
45
  "subtotal": li.subtotal,
46
+ "price_source": li.price_source,
47
  }
48
  for li in est.line_items
49
  ],
quillwright/api/send.py ADDED
@@ -0,0 +1,285 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Finalize & Send (S10) — deliver a finished estimate to the customer by SMS or
2
+ email, with the same honest framing as the rest of Quillwright (ADR-0005).
3
+
4
+ Three-state resolution, mirroring ``FF_REAL_MODELS``:
5
+
6
+ - **real** — ``FF_SEND_ENABLED=1`` AND provider creds present: the message is
7
+ actually transmitted (Twilio MMS / SendGrid email). This is the local/demo
8
+ path; the providers are heavy, optional, third-party deps that are NOT in the
9
+ Space requirements (lazy-imported, like the ``[embed]``/``[audio]`` extras).
10
+ - **mock** — default / public Space: the estimate is *drafted* and a confirmation
11
+ is returned, but nothing is transmitted (``transmitted=False``,
12
+ ``status="drafted"``). Twilio creds can't live on a public Space, so the Space
13
+ never sends — and it says so honestly rather than claiming a send it didn't do.
14
+
15
+ Facts-from-Tools (ADR-0004) holds: send introduces no numbers. The PDF and the
16
+ summary line both come from ``recalc_estimate`` — the same server-authoritative
17
+ totals the customer-facing PDF/JSON already show.
18
+
19
+ MMS cannot attach a local file, so the SMS path needs a *public* PDF URL
20
+ (``pdf_url``) to hand Twilio as the media URL; the server mints one via the
21
+ tokenized ``/api/estimate_pdf/{token}`` route. Email attaches the PDF bytes inline,
22
+ so it needs no public URL.
23
+ """
24
+
25
+ import os
26
+ from collections.abc import Callable
27
+
28
+ from quillwright.api.recalc import recalc_estimate
29
+
30
+ CHANNELS = ("sms", "email")
31
+
32
+
33
+ class SendError(Exception):
34
+ """Raised when a send is refused (bad input) or a provider fails. Loud by
35
+ design — we never silently downgrade a requested send to a no-op."""
36
+
37
+
38
+ def resolve_send_mode() -> str:
39
+ """'real' only when explicitly enabled; 'mock' otherwise (the Space default)."""
40
+ return "real" if os.environ.get("FF_SEND_ENABLED") == "1" else "mock"
41
+
42
+
43
+ # Required creds per channel — checked up front in real mode so a missing var
44
+ # fails loud with a clean message (never leaking the raw key name to the client).
45
+ # Email has two backends; the first whose creds are fully present is used (Gmail SMTP
46
+ # preferred — no sender-verification step, no extra dep — else SendGrid).
47
+ _EMAIL_BACKENDS = {
48
+ "gmail": ("GMAIL_ADDRESS", "GMAIL_APP_PASSWORD"),
49
+ "sendgrid": ("SENDGRID_API_KEY", "FF_SEND_FROM_EMAIL"),
50
+ }
51
+ _REQUIRED_ENV = {
52
+ "sms": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN", "FF_SEND_FROM"),
53
+ }
54
+
55
+
56
+ def _email_backend() -> str | None:
57
+ """The first email backend whose creds are all present, or None if none configured."""
58
+ for name, keys in _EMAIL_BACKENDS.items():
59
+ if all(os.environ.get(k) for k in keys):
60
+ return name
61
+ return None
62
+
63
+
64
+ def _require_provider_config(channel: str) -> None:
65
+ """Raise a SendError naming the CHANNEL (not the missing env var) if real-mode
66
+ creds are absent — so an HTTP 400 reply never discloses internal config keys."""
67
+ if channel == "email":
68
+ if _email_backend() is None:
69
+ raise SendError(
70
+ "email send is not configured on this machine. "
71
+ "Set FF_SEND_ENABLED + a Gmail or SendGrid email backend to enable it."
72
+ )
73
+ return
74
+ missing = [k for k in _REQUIRED_ENV.get(channel, ()) if not os.environ.get(k)]
75
+ if missing:
76
+ raise SendError(
77
+ f"{channel} send is not configured on this machine. "
78
+ f"Set FF_SEND_ENABLED + the {channel} provider credentials to enable it."
79
+ )
80
+
81
+
82
+ def estimate_summary_line(rows: list[dict], job_title: str, tax_rate: float) -> str:
83
+ """A one-line, customer-safe summary with the authoritative total
84
+ (Facts-from-Tools — the total comes from recalc, never from free text)."""
85
+ est = recalc_estimate(rows, job_title=job_title, tax_rate=tax_rate)
86
+ n = len(est["line_items"])
87
+ items = "item" if n == 1 else "items"
88
+ return f"{job_title}: {n} {items}, total ${est['total']:.2f}"
89
+
90
+
91
+ def _render_pdf_bytes(rows: list[dict], job_title: str, tax_rate: float) -> bytes:
92
+ """Render the (edited) estimate to PDF bytes via the same path as /api/pdf."""
93
+ import tempfile
94
+
95
+ from quillwright.models import Estimate, LineItem
96
+ from quillwright.pdf import estimate_to_pdf
97
+
98
+ est = Estimate(
99
+ job_title=job_title,
100
+ line_items=[
101
+ LineItem(
102
+ description=str(r.get("description", "")),
103
+ quantity=float(r.get("quantity", 1) or 0),
104
+ unit=str(r.get("unit", "ea")),
105
+ rate=float(r.get("rate", 0) or 0),
106
+ price_source="user",
107
+ )
108
+ for r in rows
109
+ ],
110
+ tax_rate=tax_rate,
111
+ )
112
+ with tempfile.NamedTemporaryFile(suffix=".pdf", delete=False) as tmp:
113
+ path = tmp.name
114
+ estimate_to_pdf(est, path)
115
+ with open(path, "rb") as f:
116
+ data = f.read()
117
+ os.unlink(path)
118
+ return data
119
+
120
+
121
+ def _validate(channel: str, recipient: str) -> str:
122
+ if channel not in CHANNELS:
123
+ raise SendError(f"Unknown channel {channel!r}; expected one of {CHANNELS}.")
124
+ recipient = (recipient or "").strip()
125
+ if not recipient:
126
+ raise SendError("Recipient is required.")
127
+ if channel == "email" and "@" not in recipient:
128
+ raise SendError(f"{recipient!r} is not a valid email address.")
129
+ return recipient
130
+
131
+
132
+ # --- default real providers (lazy third-party imports) ---------------------
133
+
134
+
135
+ def _default_sms_provider(*, recipient: str, body: str, media_url: str, summary: str) -> dict:
136
+ """Send an MMS via Twilio. Imported lazily so neither the Space nor stub mode
137
+ needs the ``twilio`` package installed. Creds from the standard Twilio env vars
138
+ (+ ``FF_SEND_FROM`` for the sending number)."""
139
+ from twilio.rest import Client # noqa: PLC0415 — lazy: optional dep, not in the Space
140
+
141
+ account_sid = os.environ["TWILIO_ACCOUNT_SID"]
142
+ auth_token = os.environ["TWILIO_AUTH_TOKEN"]
143
+ from_number = os.environ["FF_SEND_FROM"]
144
+ client = Client(account_sid, auth_token)
145
+ msg = client.messages.create(
146
+ to=recipient,
147
+ from_=from_number,
148
+ body=body,
149
+ media_url=[media_url] if media_url else None,
150
+ )
151
+ return {"sid": msg.sid}
152
+
153
+
154
+ def _default_email_provider(
155
+ *, recipient: str, subject: str, body: str, pdf_bytes: bytes, filename: str
156
+ ) -> dict:
157
+ """Send an email with the PDF attached via SendGrid (Twilio's email product —
158
+ keeps everything on the one Twilio account). Lazy-imported optional dep."""
159
+ import base64 # noqa: PLC0415
160
+
161
+ from sendgrid import SendGridAPIClient # noqa: PLC0415 — lazy: optional dep
162
+ from sendgrid.helpers.mail import ( # noqa: PLC0415
163
+ Attachment,
164
+ Disposition,
165
+ FileContent,
166
+ FileName,
167
+ FileType,
168
+ Mail,
169
+ )
170
+
171
+ message = Mail(
172
+ from_email=os.environ["FF_SEND_FROM_EMAIL"],
173
+ to_emails=recipient,
174
+ subject=subject,
175
+ plain_text_content=body,
176
+ )
177
+ message.attachment = Attachment(
178
+ FileContent(base64.b64encode(pdf_bytes).decode()),
179
+ FileName(filename),
180
+ FileType("application/pdf"),
181
+ Disposition("attachment"),
182
+ )
183
+ resp = SendGridAPIClient(os.environ["SENDGRID_API_KEY"]).send(message)
184
+ return {"id": resp.headers.get("X-Message-Id", "sendgrid-accepted")}
185
+
186
+
187
+ def _gmail_email_provider(
188
+ *, recipient: str, subject: str, body: str, pdf_bytes: bytes, filename: str
189
+ ) -> dict:
190
+ """Send an email with the PDF attached via Gmail SMTP. Uses only the stdlib
191
+ (``smtplib`` + ``email``) — no third-party dep, nothing in the Space requirements —
192
+ and an App Password (``GMAIL_APP_PASSWORD``), so there's no SendGrid sender-
193
+ verification step. The from-address is the Gmail account itself."""
194
+ import smtplib # noqa: PLC0415 — stdlib, lazy to keep the import surface small
195
+ from email.message import EmailMessage # noqa: PLC0415
196
+
197
+ sender = os.environ["GMAIL_ADDRESS"]
198
+ msg = EmailMessage()
199
+ msg["From"] = sender
200
+ msg["To"] = recipient
201
+ msg["Subject"] = subject
202
+ msg.set_content(body)
203
+ msg.add_attachment(pdf_bytes, maintype="application", subtype="pdf", filename=filename)
204
+ with smtplib.SMTP_SSL("smtp.gmail.com", 465) as server:
205
+ server.login(sender, os.environ["GMAIL_APP_PASSWORD"])
206
+ server.send_message(msg)
207
+ return {"id": f"gmail:{sender}"}
208
+
209
+
210
+ def _resolve_email_provider() -> Callable:
211
+ """The real email provider for the configured backend (Gmail SMTP preferred)."""
212
+ return _gmail_email_provider if _email_backend() == "gmail" else _default_email_provider
213
+
214
+
215
+ # --- the one entry point ---------------------------------------------------
216
+
217
+
218
+ def send_estimate(
219
+ *,
220
+ channel: str,
221
+ recipient: str,
222
+ rows: list[dict],
223
+ job_title: str = "Estimate",
224
+ tax_rate: float = 0.13,
225
+ mode: str | None = None,
226
+ pdf_url: str | None = None,
227
+ sms_provider: Callable | None = None,
228
+ email_provider: Callable | None = None,
229
+ ) -> dict:
230
+ """Send (or, in mock mode, draft) the finished estimate to ``recipient``.
231
+
232
+ Returns a structured result the UI renders directly::
233
+
234
+ {status: "sent"|"drafted", transmitted: bool, channel, recipient,
235
+ summary, provider_id}
236
+
237
+ Providers are injectable for tests; the defaults lazily import Twilio/SendGrid.
238
+ """
239
+ recipient = _validate(channel, recipient)
240
+ mode = mode or resolve_send_mode()
241
+ summary = estimate_summary_line(rows, job_title=job_title, tax_rate=tax_rate)
242
+
243
+ base = {
244
+ "channel": channel,
245
+ "recipient": recipient,
246
+ "summary": summary,
247
+ "provider_id": None,
248
+ }
249
+
250
+ if mode != "real":
251
+ # Space / disabled: draft only, transmit nothing — and say so.
252
+ return {**base, "status": "drafted", "transmitted": False}
253
+
254
+ body = f"Here is your estimate — {summary}. (AI-generated draft; review before accepting.)"
255
+ try:
256
+ if channel == "sms":
257
+ if not pdf_url:
258
+ raise SendError(
259
+ "SMS/MMS send requires a public pdf_url to attach (Twilio cannot "
260
+ "attach a local file). None was provided."
261
+ )
262
+ if sms_provider is None: # only the real default provider needs creds
263
+ _require_provider_config("sms")
264
+ provider = sms_provider or _default_sms_provider
265
+ result = provider(recipient=recipient, body=body, media_url=pdf_url, summary=summary)
266
+ provider_id = result.get("sid")
267
+ else: # email
268
+ if email_provider is None:
269
+ _require_provider_config("email")
270
+ provider = email_provider or _resolve_email_provider()
271
+ pdf_bytes = _render_pdf_bytes(rows, job_title=job_title, tax_rate=tax_rate)
272
+ result = provider(
273
+ recipient=recipient,
274
+ subject=f"Your estimate — {job_title}",
275
+ body=body,
276
+ pdf_bytes=pdf_bytes,
277
+ filename="estimate.pdf",
278
+ )
279
+ provider_id = result.get("id")
280
+ except SendError:
281
+ raise
282
+ except Exception as exc: # noqa: BLE001 — any provider failure becomes a loud SendError
283
+ raise SendError(f"{channel} send failed: {exc}") from exc
284
+
285
+ return {**base, "status": "sent", "transmitted": True, "provider_id": provider_id}
quillwright/api/tools_api.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quillwright tool endpoints for a voice agent (ElevenLabs Conversational AI).
2
+
3
+ ElevenLabs owns the phone call, the speech-to-text, the dialogue, and the (great) TTS.
4
+ Quillwright stays the source of truth: the agent calls these small JSON tools to forge
5
+ and refine an estimate, and every customer-facing number comes from a tool response —
6
+ never the agent's free speech (Facts-from-Tools, ADR-0004).
7
+
8
+ Each tool takes a ``session_id`` (the agent passes its conversation id) so refinement
9
+ turns edit the same estimate. State is in-process and demo-scoped, like the pairing store
10
+ and the Twilio call state — a durable backend is a swap behind this same interface.
11
+
12
+ Tools:
13
+ - ``forge(session_id, description)`` → itemized estimate + total from a job description
14
+ - ``edit(session_id, request)`` → add / remove / change a line (catalog-priced)
15
+ - ``lookup_price(item)`` → a single catalog price (read-only)
16
+ - ``text_estimate(session_id, to)`` → SMS the estimate PDF to the caller
17
+ """
18
+
19
+ # session_id -> {"rows": list[dict], "job_title": str, "tax_rate": float}
20
+ _SESSIONS: dict[str, dict] = {}
21
+
22
+ _JOB_TITLE = "Phone estimate"
23
+ _TAX_RATE = 0.13
24
+
25
+
26
+ def reset_sessions() -> None:
27
+ """Drop all voice-agent session state (tests)."""
28
+ _SESSIONS.clear()
29
+
30
+
31
+ def _session(session_id: str) -> dict:
32
+ return _SESSIONS.setdefault(
33
+ session_id, {"rows": [], "job_title": _JOB_TITLE, "tax_rate": _TAX_RATE}
34
+ )
35
+
36
+
37
+ def _rows_from_est(est: dict) -> list[dict]:
38
+ return [
39
+ {
40
+ "description": li["description"],
41
+ "quantity": li["quantity"],
42
+ "unit": li["unit"],
43
+ "rate": li["rate"],
44
+ }
45
+ for li in est["line_items"]
46
+ ]
47
+
48
+
49
+ def _spoken_items(est: dict) -> list[dict]:
50
+ """A compact, speech-friendly view of the lines (no internal fields)."""
51
+ return [
52
+ {"description": li["description"], "quantity": li["quantity"], "rate": li["rate"]}
53
+ for li in est["line_items"]
54
+ ]
55
+
56
+
57
+ def forge(session_id: str, description: str) -> dict:
58
+ """Forge an estimate from a spoken job description; store it on the session."""
59
+ from quillwright.api.estimate import forge_estimate
60
+
61
+ forged = forge_estimate(description or "", trade="hvac")
62
+ est = forged.get("estimate")
63
+ if est is None or not est.get("line_items"):
64
+ return {
65
+ "ok": False,
66
+ "message": "I couldn't build an estimate from that. "
67
+ "Try naming the parts and the labor.",
68
+ }
69
+ sess = _session(session_id)
70
+ sess["rows"] = _rows_from_est(est)
71
+ return {
72
+ "ok": True,
73
+ "items": _spoken_items(est),
74
+ "item_count": len(est["line_items"]),
75
+ "total": round(est["total"], 2),
76
+ }
77
+
78
+
79
+ def edit(session_id: str, request: str) -> dict:
80
+ """Apply a spoken edit (add / remove / change) to the session's estimate. The catalog
81
+ owns every price (Facts-from-Tools); returns the assistant's reply + the new total."""
82
+ from quillwright.api.chat import chat_about_estimate
83
+
84
+ sess = _session(session_id)
85
+ out = chat_about_estimate(request or "", sess["rows"], tax_rate=sess["tax_rate"])
86
+ est = out["estimate"]
87
+ sess["rows"] = _rows_from_est(est)
88
+ return {
89
+ "ok": True,
90
+ "reply": out["reply"],
91
+ "items": _spoken_items(est),
92
+ "item_count": len(est["line_items"]),
93
+ "total": round(est["total"], 2),
94
+ }
95
+
96
+
97
+ def lookup_price(item: str) -> dict:
98
+ """A single catalog price (read-only) so the agent can answer 'how much is X?'."""
99
+ from quillwright.api.estimate import CATALOG
100
+
101
+ hit = CATALOG.lookup(item or "")
102
+ if not hit:
103
+ return {"found": False, "item": item}
104
+ return {
105
+ "found": True,
106
+ "description": hit["description"],
107
+ "rate": hit["rate"],
108
+ "unit": hit["unit"],
109
+ }
110
+
111
+
112
+ def text_estimate(session_id: str, to: str, base_url: str | None = None, sms=None) -> dict:
113
+ """SMS the estimate PDF to the caller. Reuses the S10 send path + tokenized PDF link.
114
+ ``sms`` is injectable for tests; otherwise the real Twilio MMS provider is used."""
115
+ import os
116
+
117
+ from quillwright.api.estimate import save_estimate_record
118
+ from quillwright.api.pdf_links import public_pdf_url, register_pdf
119
+ from quillwright.api.recalc import recalc_estimate
120
+ from quillwright.api.send import _default_sms_provider, _render_pdf_bytes
121
+
122
+ sess = _session(session_id)
123
+ rows, job_title, tax_rate = sess["rows"], sess["job_title"], sess["tax_rate"]
124
+ if not rows:
125
+ return {"ok": False, "message": "There's no estimate to send yet."}
126
+ if not to:
127
+ return {"ok": False, "message": "I need a phone number to text it to."}
128
+
129
+ base = (base_url if base_url is not None else os.environ.get("FF_PUBLIC_BASE_URL", "")).rstrip(
130
+ "/"
131
+ )
132
+ est = recalc_estimate(rows, job_title=job_title, tax_rate=tax_rate)
133
+ save_estimate_record(rows, job_title, tax_rate, thread=[]) # persist the draft (ADR-0013)
134
+ n = len(est["line_items"])
135
+ send = sms or _default_sms_provider
136
+ try:
137
+ pdf_bytes = _render_pdf_bytes(rows, job_title=job_title, tax_rate=tax_rate)
138
+ token = register_pdf(pdf_bytes)
139
+ media_url = public_pdf_url(token, base_url=base or "")
140
+ send(
141
+ recipient=to,
142
+ body=(
143
+ f"Your Quillwright estimate: {n} item{'s' if n != 1 else ''}, "
144
+ f"total ${est['total']:.2f}. AI-generated draft — review before accepting."
145
+ ),
146
+ media_url=media_url,
147
+ summary="",
148
+ )
149
+ return {"ok": True, "sent": True, "total": round(est["total"], 2)}
150
+ except Exception as exc: # noqa: BLE001 — report a clean failure to the agent
151
+ return {"ok": False, "sent": False, "message": f"Couldn't text it: {exc}"}
quillwright/api/transcribe.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transcribe a spoken voice note into the text the agent works from (ADR-0009).
2
+
3
+ Thin adapter over the Audio role: file path in, {transcript} out. The model is
4
+ injectable for tests; in production it resolves to Cohere Transcribe on-device.
5
+ """
6
+
7
+ import os
8
+
9
+
10
+ def _resolve_audio():
11
+ """The Audio role, by env: hosted Omni (Best Stack) when its Modal URL is
12
+ configured, on-device STT when FF_REAL_MODELS=1, else None (typed note)."""
13
+ from quillwright.resolver import modal_resolver_if_configured
14
+
15
+ modal = modal_resolver_if_configured("audio")
16
+ if modal is not None:
17
+ return modal.for_role("audio")
18
+ if os.environ.get("FF_REAL_MODELS") == "1":
19
+ from quillwright.resolver import ModelResolver
20
+
21
+ return ModelResolver(mode="private", backend="ollama").for_role("audio")
22
+ return None
23
+
24
+
25
+ def transcribe_audio(path: str, model=None) -> dict:
26
+ """Transcribe the audio at `path`. Returns {"transcript": str}.
27
+
28
+ `model` is injectable (tests); otherwise resolves the Audio role. The transcript
29
+ is whitespace-normalized so it drops cleanly into the note field.
30
+ """
31
+ asr = model if model is not None else _resolve_audio()
32
+ if asr is None:
33
+ return {"transcript": ""}
34
+ text = (asr.transcribe(path) or "").strip()
35
+ return {"transcript": text}
quillwright/api/voice.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inbound voice-call capture (S12) — call a number, the agent forges an estimate.
2
+
3
+ Flow (all reuse; no new business logic):
4
+
5
+ 1. Twilio routes an inbound call to ``POST /api/voice/incoming``. We answer with
6
+ TwiML: a short greeting then ``<Record>``, which posts the ``RecordingUrl`` to
7
+ ``POST /api/voice/recording`` when the caller hangs up.
8
+ 2. The recording webhook downloads the ``.wav``/``.mp3``, transcribes it (Audio role
9
+ — Nemotron Omni on the Best Stack, Cohere Transcribe on the Private Stack, same
10
+ ``transcribe_audio`` resolution as the mic button), forges an estimate, and saves
11
+ it as a **DRAFT** under ``account_id="demo"`` (ADR-0013). It then ``<Say>``s the
12
+ spoken total and ``<Gather>``s the caller's reply (Tier A — a conversation).
13
+ 3. Each reply hits ``POST /api/voice/refine`` with Twilio's own ``SpeechResult``
14
+ transcript. "Done/no" → recalc, persist, text the PDF, end the call. Otherwise the
15
+ spoken edit runs through the SAME ``chat_about_estimate`` ops (add / remove / change)
16
+ the desktop chat uses, the new total is read back, and we ``<Gather>`` again. Per-call
17
+ state is held under the Twilio ``CallSid`` (in-process, demo-scoped).
18
+
19
+ Honesty (ADR-0004, ADR-0013): the estimate's numbers all come from the catalog +
20
+ ``recalc`` (Facts-from-Tools); the call produces a draft a human approves later. On a
21
+ call the agent runs to completion without the interactive Agent Pause (``forge_estimate``
22
+ is the non-streaming path — a missing price is auto-flagged in the trace, never blocks).
23
+
24
+ The public base URL is read from ``FF_PUBLIC_BASE_URL`` (the ngrok/cloudflared tunnel),
25
+ so Twilio can fetch the recording-action URL and the PDF media URL. SMS reuses the S10
26
+ ``send_estimate`` SMS provider and the tokenized PDF registry — nothing new is sent.
27
+ """
28
+
29
+ import os
30
+ from collections.abc import Callable
31
+ from xml.sax.saxutils import escape
32
+
33
+ # Spoken voice for every <Say>. Amazon Polly Neural voices (rendered by Twilio at no extra
34
+ # cost beyond standard call rates) sound far more natural than the default. Override with
35
+ # FF_VOICE if you prefer another (e.g. Polly.Joanna-Neural, Polly.Stephen-Neural).
36
+ VOICE = os.environ.get("FF_VOICE", "Polly.Matthew-Neural")
37
+
38
+
39
+ def _say(message: str) -> str:
40
+ """A <Say> with the configured natural voice."""
41
+ return f'<Say voice="{escape(VOICE)}">{escape(message)}</Say>'
42
+
43
+
44
+ def public_base_url() -> str:
45
+ """The tunnel's public base URL (FF_PUBLIC_BASE_URL), trailing slash stripped, or ''."""
46
+ return os.environ.get("FF_PUBLIC_BASE_URL", "").rstrip("/")
47
+
48
+
49
+ def _action_url(path: str, base_url: str | None = None) -> str:
50
+ """An absolute URL on the public base when known, else a relative path (Twilio
51
+ resolves a relative <Record action> against the request host)."""
52
+ base = (base_url if base_url is not None else public_base_url()).rstrip("/")
53
+ return f"{base}{path}" if base else path
54
+
55
+
56
+ def greeting_twiml(base_url: str | None = None) -> str:
57
+ """Answer an inbound call: greet, then record the caller's job description.
58
+
59
+ ``<Record>`` posts the RecordingUrl to /api/voice/recording on hang-up (or after the
60
+ silence timeout). ``playBeep`` cues the caller; ``maxLength`` caps a runaway call.
61
+ """
62
+ action = _action_url("/api/voice/recording", base_url)
63
+ return (
64
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
65
+ "<Response>"
66
+ + _say(
67
+ "Welcome to Quillwright. After the beep, describe the job — the parts you "
68
+ "used and the labor — then stop talking. I'll forge an estimate, read it back, "
69
+ "and you can tell me what to change."
70
+ )
71
+ + f'<Record action="{escape(action)}" method="POST" maxLength="120" '
72
+ 'playBeep="true" timeout="5" />'
73
+ + _say("I didn't catch a recording. Goodbye.")
74
+ + "</Response>"
75
+ )
76
+
77
+
78
+ def _say_response(message: str) -> str:
79
+ """A bare spoken TwiML response (no recording)."""
80
+ return f'<?xml version="1.0" encoding="UTF-8"?>\n<Response>{_say(message)}</Response>'
81
+
82
+
83
+ # --- Conversational refine loop (Tier A): after forging, the agent reads the total and
84
+ # keeps the call open, asking "anything else?" via <Gather input="speech">. Each reply
85
+ # is a new /api/voice/refine turn that runs the SAME chat_about_estimate ops (add /
86
+ # remove / change), so Facts-from-Tools holds — the agent only READS totals that recalc
87
+ # produced. State is held per Twilio CallSid (in-process, demo-scoped, like pairing). ---
88
+
89
+ # call_sid -> {"rows": list[dict], "job_title": str, "tax_rate": float, "from_number": str}
90
+ _CALLS: dict[str, dict] = {}
91
+
92
+ # Phrases that end the conversation (caller says they're done).
93
+ _DONE_WORDS = (
94
+ "no",
95
+ "nope",
96
+ "nothing",
97
+ "that's it",
98
+ "thats it",
99
+ "done",
100
+ "all set",
101
+ "good",
102
+ "send it",
103
+ )
104
+
105
+
106
+ def _call_state(call_sid: str) -> dict | None:
107
+ return _CALLS.get(call_sid)
108
+
109
+
110
+ def reset_calls() -> None:
111
+ """Drop all in-flight call + job state (tests)."""
112
+ _CALLS.clear()
113
+ _JOBS.clear()
114
+
115
+
116
+ def _is_done(speech: str) -> bool:
117
+ s = (speech or "").strip().lower()
118
+ if not s:
119
+ return False
120
+ return any(w in s for w in _DONE_WORDS)
121
+
122
+
123
+ def _ask_twiml(message: str, base_url: str | None = None) -> str:
124
+ """Speak `message`, then <Gather> the caller's spoken reply to /api/voice/refine.
125
+ If they stay silent, end politely (the Gather falls through to the closing Say)."""
126
+ action = _action_url("/api/voice/refine", base_url)
127
+ return (
128
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
129
+ "<Response>" + f'<Gather input="speech" method="POST" action="{escape(action)}" '
130
+ 'speechTimeout="auto" timeout="6">'
131
+ + _say(message)
132
+ + "</Gather>"
133
+ + _say("I didn't catch that — I'll text you what I have. Goodbye.")
134
+ + "</Response>"
135
+ )
136
+
137
+
138
+ def _download_recording(url: str) -> str:
139
+ """Fetch a Twilio RecordingUrl to a local temp file. Twilio serves the media at
140
+ ``<url>.wav`` (a safer container for Omni than the browser's webm). Auth with the
141
+ standard Twilio creds when present (recordings on a real account are protected)."""
142
+ import tempfile
143
+ import time
144
+
145
+ import requests # already a core dep
146
+
147
+ media_url = url if url.endswith((".wav", ".mp3")) else f"{url}.wav"
148
+ auth = None
149
+ sid, token = os.environ.get("TWILIO_ACCOUNT_SID"), os.environ.get("TWILIO_AUTH_TOKEN")
150
+ if sid and token:
151
+ auth = (sid, token)
152
+ # Twilio posts the recording webhook the instant recording ends, but the media file
153
+ # is often not encoded/available for a beat — an immediate GET 404s/403s. Retry a few
154
+ # times with a short backoff so the not-ready race doesn't fail the call.
155
+ last = None
156
+ for attempt in range(5):
157
+ resp = requests.get(media_url, auth=auth, timeout=20)
158
+ if resp.status_code == 200 and resp.content:
159
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
160
+ tmp.write(resp.content)
161
+ return tmp.name
162
+ last = resp
163
+ if resp.status_code not in (404, 403, 401):
164
+ break # a different error won't fix itself by waiting
165
+ time.sleep(1.5)
166
+ if last is not None:
167
+ last.raise_for_status()
168
+ raise RuntimeError(f"could not fetch recording at {media_url}")
169
+
170
+
171
+ def _send_sms(*, to: str, body: str, media_url: str) -> dict:
172
+ """Text the caller via Twilio (lazy import — same optional [send] dep as S10)."""
173
+ from twilio.rest import Client # noqa: PLC0415 — lazy: optional dep, not in the Space
174
+
175
+ client = Client(os.environ["TWILIO_ACCOUNT_SID"], os.environ["TWILIO_AUTH_TOKEN"])
176
+ msg = client.messages.create(
177
+ to=to,
178
+ from_=os.environ["FF_SEND_FROM"],
179
+ body=body,
180
+ media_url=[media_url] if media_url else None,
181
+ )
182
+ return {"sid": msg.sid}
183
+
184
+
185
+ def _rows_from_est(est: dict) -> list[dict]:
186
+ """The editable rows (no subtotal/source) the chat ops + PDF renderer expect."""
187
+ return [
188
+ {
189
+ "description": li["description"],
190
+ "quantity": li["quantity"],
191
+ "unit": li["unit"],
192
+ "rate": li["rate"],
193
+ }
194
+ for li in est["line_items"]
195
+ ]
196
+
197
+
198
+ def _summary(est: dict) -> str:
199
+ n = len(est["line_items"])
200
+ items = "item" if n == 1 else "items"
201
+ return f"{n} {items}, totaling {est['total']:.2f} dollars"
202
+
203
+
204
+ def _text_pdf(*, rows, job_title, tax_rate, total, n, from_number, base, sms) -> bool:
205
+ """Render + register the PDF and text it to the caller. Best-effort: returns True on
206
+ a send, False on any failure (the draft is already saved either way)."""
207
+ from quillwright.api.pdf_links import public_pdf_url, register_pdf
208
+ from quillwright.api.send import _render_pdf_bytes
209
+
210
+ if not from_number:
211
+ return False
212
+ try:
213
+ pdf_bytes = _render_pdf_bytes(rows, job_title=job_title, tax_rate=tax_rate)
214
+ token = register_pdf(pdf_bytes)
215
+ media_url = public_pdf_url(token, base_url=base or "")
216
+ items = "item" if n == 1 else "items"
217
+ sms(
218
+ to=from_number,
219
+ body=(
220
+ f"Your Quillwright estimate: {n} {items}, total ${total:.2f}. "
221
+ "AI-generated draft — review before accepting."
222
+ ),
223
+ media_url=media_url,
224
+ )
225
+ return True
226
+ except Exception: # noqa: BLE001 — texting is best-effort; the draft is saved
227
+ return False
228
+
229
+
230
+ def handle_recording(
231
+ *,
232
+ recording_url: str,
233
+ from_number: str,
234
+ call_sid: str = "default",
235
+ download: Callable[[str], str] | None = None,
236
+ transcribe: Callable[[str], dict] | None = None,
237
+ sms: Callable | None = None,
238
+ base_url: str | None = None,
239
+ ) -> dict:
240
+ """Transcribe the recording, forge + save a draft estimate, then ASK the caller if
241
+ they want to change anything (the conversational refine loop — Tier A).
242
+
243
+ Returns ``{"estimate": <dict|None>, "twiml": <spoken+gather>, "transcript": str}``.
244
+ The PDF is NOT texted here — it goes out when the caller says they're done (see
245
+ ``handle_refine``). Per-call state is held under ``call_sid``. Side-effects
246
+ (download / SMS) are injectable so tests need no network or twilio.
247
+ """
248
+ from quillwright.api.estimate import estimate_store, forge_estimate
249
+ from quillwright.api.transcribe import transcribe_audio
250
+
251
+ download = download or _download_recording
252
+ transcribe = transcribe or (lambda path: transcribe_audio(path))
253
+ base = (base_url if base_url is not None else public_base_url()).rstrip("/")
254
+
255
+ path = download(recording_url)
256
+ transcript = (transcribe(path) or {}).get("transcript", "").strip()
257
+ if not transcript:
258
+ return {
259
+ "estimate": None,
260
+ "transcript": "",
261
+ "twiml": _say_response(
262
+ "Sorry, I couldn't make out the job from that recording. "
263
+ "Please call back and describe the parts and labor after the beep."
264
+ ),
265
+ }
266
+
267
+ forged = forge_estimate(transcript, trade="hvac")
268
+ est = forged.get("estimate")
269
+ if est is None or not est.get("line_items"):
270
+ return {
271
+ "estimate": None,
272
+ "transcript": transcript,
273
+ "twiml": _say_response(
274
+ "I heard the job but couldn't build an estimate from it. "
275
+ "I've made a note — please call back with the parts and labor."
276
+ ),
277
+ }
278
+
279
+ # Hold the rows for this call so refine turns edit the same estimate (forge_estimate
280
+ # already auto-saved a DRAFT — ADR-0013; this is the same store the desktop reads).
281
+ _CALLS[call_sid] = {
282
+ "rows": _rows_from_est(est),
283
+ "job_title": est["job_title"],
284
+ "tax_rate": est["tax_rate"],
285
+ "from_number": from_number,
286
+ }
287
+ estimate_store() # touch so a misconfigured store surfaces in logs
288
+ spoken = (
289
+ f"Done. I forged an estimate with {_summary(est)}. "
290
+ "Want to add or change anything, or should I text it to you?"
291
+ )
292
+ return {"estimate": est, "transcript": transcript, "twiml": _ask_twiml(spoken, base)}
293
+
294
+
295
+ # --- Async job pattern: forge+transcribe take ~tens of seconds (model load + brain),
296
+ # far over Twilio's ~15s webhook timeout. So the recording webhook kicks the work off
297
+ # on a background thread and returns a holding response immediately; Twilio is parked on
298
+ # a <Pause>+<Redirect> to /api/voice/status, which polls until the job finishes. Each
299
+ # webhook response stays well under the timeout. ---
300
+
301
+ # call_sid -> {"status": "working"|"done"|"error", "twiml": <ask TwiML once done>}
302
+ _JOBS: dict[str, dict] = {}
303
+
304
+
305
+ def _hold_twiml(message: str, base_url: str | None = None) -> str:
306
+ """Speak a short status line, pause, then redirect to /api/voice/status to poll again."""
307
+ action = _action_url("/api/voice/status", base_url)
308
+ return (
309
+ '<?xml version="1.0" encoding="UTF-8"?>\n'
310
+ "<Response>"
311
+ + _say(message)
312
+ + '<Pause length="3" />'
313
+ + f'<Redirect method="POST">{escape(action)}</Redirect>'
314
+ + "</Response>"
315
+ )
316
+
317
+
318
+ def start_recording_job(
319
+ *, recording_url: str, from_number: str, call_sid: str, base_url: str | None = None
320
+ ) -> str:
321
+ """Kick the forge off on a background thread; return holding TwiML immediately.
322
+ The webhook never blocks on the slow work (model load + brain)."""
323
+ import threading
324
+
325
+ base = (base_url if base_url is not None else public_base_url()).rstrip("/")
326
+ _JOBS[call_sid] = {"status": "working", "twiml": None}
327
+
328
+ def _work():
329
+ try:
330
+ result = handle_recording(
331
+ recording_url=recording_url,
332
+ from_number=from_number,
333
+ call_sid=call_sid,
334
+ base_url=base,
335
+ )
336
+ _JOBS[call_sid] = {"status": "done", "twiml": result["twiml"]}
337
+ except Exception as exc: # noqa: BLE001 — surface as an error status, not a crash
338
+ print(f"[quillwright] voice forge job failed: {exc}", flush=True)
339
+ _JOBS[call_sid] = {
340
+ "status": "error",
341
+ "twiml": _say_response(
342
+ "Sorry, I couldn't build that estimate. Please call back and try again."
343
+ ),
344
+ }
345
+
346
+ threading.Thread(target=_work, daemon=True).start()
347
+ return _hold_twiml("Got it. I'm forging your estimate now — this takes a moment.", base)
348
+
349
+
350
+ def handle_status(*, call_sid: str, base_url: str | None = None) -> str:
351
+ """Poll the background forge: still working → hold + redirect again; done → the ask
352
+ (or error) TwiML the job produced. Unknown call → polite fallback."""
353
+ base = (base_url if base_url is not None else public_base_url()).rstrip("/")
354
+ job = _JOBS.get(call_sid)
355
+ if job is None:
356
+ return _say_response(
357
+ "Sorry, I lost track of that estimate. Please call back to start again."
358
+ )
359
+ if job["status"] == "working":
360
+ return _hold_twiml("Still working on it — just a few more seconds.", base)
361
+ # done or error: hand back the prepared TwiML and clear the job marker.
362
+ _JOBS.pop(call_sid, None)
363
+ return job["twiml"]
364
+
365
+
366
+ def handle_refine(
367
+ *,
368
+ call_sid: str,
369
+ speech_result: str,
370
+ base_url: str | None = None,
371
+ sms: Callable | None = None,
372
+ ) -> dict:
373
+ """One caller turn in the refine loop. If they're done, text the PDF and end; else
374
+ apply the spoken edit through the SAME chat_about_estimate ops (Facts-from-Tools —
375
+ the catalog owns every price) and ask again.
376
+
377
+ Returns ``{"estimate": <dict|None>, "twiml": str}``. ``sms`` is injectable for tests.
378
+ """
379
+ from quillwright.api.chat import chat_about_estimate
380
+ from quillwright.api.estimate import save_estimate_record
381
+
382
+ sms = sms or _send_sms
383
+ base = (base_url if base_url is not None else public_base_url()).rstrip("/")
384
+ state = _CALLS.get(call_sid)
385
+ if state is None:
386
+ # Lost the thread (server restart / stale call) — fail politely, don't crash.
387
+ return {
388
+ "estimate": None,
389
+ "twiml": _say_response(
390
+ "Sorry, I lost track of that estimate. Please call back to start again."
391
+ ),
392
+ }
393
+
394
+ rows = state["rows"]
395
+ job_title, tax_rate = state["job_title"], state["tax_rate"]
396
+
397
+ # Caller signalled they're finished → recalc to authoritative numbers, persist the
398
+ # final draft, text the PDF, and end the call.
399
+ if _is_done(speech_result):
400
+ from quillwright.api.recalc import recalc_estimate
401
+
402
+ est = recalc_estimate(rows, job_title=job_title, tax_rate=tax_rate)
403
+ save_estimate_record(rows, job_title, tax_rate, thread=[]) # update the saved draft
404
+ n = len(est["line_items"])
405
+ sent = _text_pdf(
406
+ rows=rows,
407
+ job_title=job_title,
408
+ tax_rate=tax_rate,
409
+ total=est["total"],
410
+ n=n,
411
+ from_number=state.get("from_number", ""),
412
+ base=base,
413
+ sms=sms,
414
+ )
415
+ _CALLS.pop(call_sid, None)
416
+ tail = (
417
+ "I've texted you the PDF. It's a draft — review before sending it on. Goodbye."
418
+ if sent
419
+ else "It's saved as a draft on your dashboard. Goodbye."
420
+ )
421
+ return {"estimate": est, "twiml": _say_response(f"Got it. {tail}")}
422
+
423
+ # Otherwise it's an edit: run it through the shared chat ops (catalog owns the price).
424
+ out = chat_about_estimate(speech_result, rows, tax_rate=tax_rate)
425
+ est = out["estimate"]
426
+ state["rows"] = _rows_from_est(est) # carry the edit forward to the next turn
427
+ save_estimate_record(state["rows"], job_title, tax_rate, thread=[]) # keep the draft current
428
+ spoken = f"{out['reply']} That's now {est['total']:.2f} dollars. Anything else?"
429
+ return {"estimate": est, "twiml": _ask_twiml(spoken, base)}
quillwright/backends/audio.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AudioModel: local speech-to-text for the spoken voice note (ADR-0009).
2
+
3
+ Wraps CohereLabs/cohere-transcribe-03-2026 (2B, #1 WER, on-device) via transformers'
4
+ canonical path — AutoProcessor + CohereAsrForConditionalGeneration, the approach the
5
+ model card documents (the generic `pipeline()` API errors on this model). Verified
6
+ locally: a trade note transcribes cleanly.
7
+
8
+ Heavy (torch + a 2B model), so the import + load are LAZY — importing this module
9
+ costs nothing; the model loads on first `.transcribe()`. Keeps the spoken note inside
10
+ the on-device Private Stack (🔌 Off the Grid). Gated repo: needs HF access + token.
11
+ """
12
+
13
+ DEFAULT_MODEL = "CohereLabs/cohere-transcribe-03-2026"
14
+
15
+
16
+ def to_wav_16k_mono(path: str) -> str:
17
+ """Return a path to a 16kHz mono WAV for `path`.
18
+
19
+ Browser/phone MediaRecorder emits a **webm** container (Opus), which librosa /
20
+ transformers' `load_audio` cannot decode ("appears to be a video file"). Twilio call
21
+ recordings can be `.mp3` too. We normalize anything that isn't already a `.wav` to
22
+ 16kHz mono PCM WAV with ffmpeg first — the format the ASR model wants, and a safer
23
+ container all round. A `.wav` input is returned unchanged (no needless transcode).
24
+
25
+ Raises RuntimeError with a clear message if ffmpeg isn't installed.
26
+ """
27
+ import os
28
+ import shutil
29
+ import subprocess
30
+ import tempfile
31
+
32
+ if path.lower().endswith(".wav"):
33
+ return path
34
+ if shutil.which("ffmpeg") is None:
35
+ raise RuntimeError(
36
+ "ffmpeg is required to transcode the voice note (browser/phone records webm, "
37
+ "which the ASR model can't read). Install it (e.g. `brew install ffmpeg`)."
38
+ )
39
+ with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
40
+ out = tmp.name
41
+ subprocess.run(
42
+ ["ffmpeg", "-y", "-i", path, "-ar", "16000", "-ac", "1", "-f", "wav", out],
43
+ check=True,
44
+ capture_output=True,
45
+ )
46
+ if not os.path.getsize(out):
47
+ raise RuntimeError(f"ffmpeg produced an empty WAV transcoding {path!r}.")
48
+ return out
49
+
50
+
51
+ class AudioModel:
52
+ def __init__(self, model: str = DEFAULT_MODEL, language: str = "en"):
53
+ self.name = model
54
+ self._language = language
55
+ self._processor = None
56
+ self._model = None
57
+
58
+ def _load(self):
59
+ if self._model is None:
60
+ from transformers import AutoProcessor, CohereAsrForConditionalGeneration
61
+
62
+ self._processor = AutoProcessor.from_pretrained(self.name)
63
+ self._model = CohereAsrForConditionalGeneration.from_pretrained(
64
+ self.name, device_map="auto"
65
+ )
66
+ return self._processor, self._model
67
+
68
+ def transcribe(self, path: str) -> str:
69
+ import os
70
+
71
+ from transformers.audio_utils import load_audio
72
+
73
+ processor, model = self._load()
74
+ # Normalize webm/m4a/mp3 → 16kHz mono WAV (load_audio can't decode webm). Clean up
75
+ # any temp file we created (but never the caller's original .wav).
76
+ wav = to_wav_16k_mono(path)
77
+ try:
78
+ audio = load_audio(wav, sampling_rate=16000)
79
+ finally:
80
+ if wav != path:
81
+ try:
82
+ os.unlink(wav)
83
+ except OSError:
84
+ pass
85
+ inputs = processor(audio, sampling_rate=16000, return_tensors="pt", language=self._language)
86
+ inputs.to(model.device, dtype=model.dtype)
87
+ outputs = model.generate(**inputs, max_new_tokens=256)
88
+ decoded = processor.decode(outputs, skip_special_tokens=True)
89
+ # decode() returns a list (one string per batch item); we transcribe one clip.
90
+ if isinstance(decoded, list):
91
+ decoded = decoded[0] if decoded else ""
92
+ return decoded
quillwright/backends/embedding.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EmbeddingModel: local text embeddings for semantic Recall (ADR-0003).
2
+
3
+ Wraps `nvidia/llama-nemotron-embed-1b-v2` via sentence-transformers — the model
4
+ card's recommended local path (NOT Ollama; it has no embeddings API). Runs fully
5
+ offline → preserves 🔌 Off the Grid and adds NVIDIA breadth.
6
+
7
+ sentence-transformers + torch are heavy (~2GB), so the import is LAZY: importing
8
+ this module costs nothing; the model loads on first `.encode()`. Per ADR-0003 the
9
+ hot path only embeds the QUERY (run vectors are cached at record time), so torch
10
+ stays out of recall-time latency.
11
+ """
12
+
13
+ DEFAULT_MODEL = "nvidia/llama-nemotron-embed-1b-v2"
14
+
15
+
16
+ class EmbeddingModel:
17
+ def __init__(self, model: str = DEFAULT_MODEL):
18
+ self.name = model
19
+ self._st = None # lazily-loaded SentenceTransformer
20
+
21
+ def _model(self):
22
+ if self._st is None:
23
+ from sentence_transformers import SentenceTransformer
24
+
25
+ self._st = SentenceTransformer(self.name, trust_remote_code=True)
26
+ return self._st
27
+
28
+ def encode(self, text: str) -> list[float]:
29
+ vec = self._model().encode(text, normalize_embeddings=True)
30
+ return vec.tolist()
quillwright/backends/modal.py ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ModalModel: the Best-Stack hosted-compute client (ADR-0005 / ADR-0009).
2
+
3
+ Same interface as OllamaModel (name + generate + chat, plus transcribe for the
4
+ Audio role) so the resolver can swap to it with `backend="modal"`. It calls the
5
+ vLLM OpenAI-compatible server deployed by the role's modal app and adapts the
6
+ response back to our internal contract:
7
+
8
+ - chat() returns {"content": str, "tool_calls": [{"function": {"name", "arguments"}}]}
9
+ where `arguments` is a DICT (vLLM/OpenAI gives it as a JSON string — we parse it),
10
+ matching what brain_loop.py expects from OllamaModel.chat().
11
+ - generate() takes an optional image_path (Best-Stack Perception via Omni) sent
12
+ as an OpenAI data-URL image_url content part.
13
+ - transcribe() sends a voice note as OpenAI input_audio (Best-Stack Audio via the
14
+ SAME Omni deployment — it is omnimodal, so one app serves two Model Roles).
15
+
16
+ Each role reads its own base URL env (printed by `modal deploy` of its app):
17
+ brain -> modal_app.py, perception/audio -> modal_omni_app.py,
18
+ multilingual -> modal_aya_app.py.
19
+ """
20
+
21
+ import base64
22
+ import json
23
+ import os
24
+ from pathlib import Path
25
+
26
+ import requests
27
+
28
+ # role -> (url env, served-model override env, default served model repo id).
29
+ # The deployed vLLM server pins the real repo; the env override exists for the
30
+ # day a variant swap shouldn't need a redeploy of this client.
31
+ ROLE_ENDPOINTS = {
32
+ "brain": (
33
+ "FF_MODAL_BRAIN_URL",
34
+ "FF_MODAL_BRAIN_MODEL",
35
+ "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-FP8",
36
+ ),
37
+ "perception": (
38
+ "FF_MODAL_OMNI_URL",
39
+ "FF_MODAL_OMNI_MODEL",
40
+ "nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-FP8",
41
+ ),
42
+ "audio": (
43
+ "FF_MODAL_OMNI_URL",
44
+ "FF_MODAL_OMNI_MODEL",
45
+ "nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-FP8",
46
+ ),
47
+ "multilingual": (
48
+ "FF_MODAL_AYA_URL",
49
+ "FF_MODAL_AYA_MODEL",
50
+ "CohereLabs/aya-expanse-8b",
51
+ ),
52
+ }
53
+
54
+
55
+ class ModalModel:
56
+ def __init__(
57
+ self,
58
+ model: str,
59
+ role: str = "brain",
60
+ base_url: str | None = None,
61
+ timeout: float = 300.0,
62
+ ):
63
+ # `model` is the label/role tag; the deployed server already pins the real
64
+ # repo id, so we send a model field vLLM accepts (the served model name).
65
+ self.name = model
66
+ url_env, model_env, default_model = ROLE_ENDPOINTS[role]
67
+ self._base = (base_url or os.environ.get(url_env, "")).rstrip("/")
68
+ if not self._base:
69
+ raise RuntimeError(
70
+ f"{url_env} is not set — deploy the Modal app for role '{role}' and "
71
+ "export the URL it prints (see quillwright/backends/modal_*.py)."
72
+ )
73
+ self._served_model = os.environ.get(model_env, default_model)
74
+ self._timeout = timeout
75
+
76
+ def _post(self, path: str, body: dict) -> dict:
77
+ resp = requests.post(f"{self._base}{path}", json=body, timeout=self._timeout)
78
+ resp.raise_for_status()
79
+ return resp.json()
80
+
81
+ def chat(self, messages: list[dict], tools: list[dict]) -> dict:
82
+ """Tool-calling chat via vLLM's OpenAI API; adapt to our message contract."""
83
+ body = {
84
+ "model": self._served_model,
85
+ "messages": _to_openai_messages(messages),
86
+ "tools": tools,
87
+ "tool_choice": "auto",
88
+ "stream": False,
89
+ }
90
+ data = self._post("/v1/chat/completions", body)
91
+ msg = (data.get("choices") or [{}])[0].get("message", {}) or {}
92
+ return {
93
+ "content": msg.get("content") or "",
94
+ "tool_calls": [_adapt_tool_call(tc) for tc in (msg.get("tool_calls") or [])],
95
+ }
96
+
97
+ def generate(self, prompt: str, image_path: str | None = None) -> str:
98
+ """Completion via the OpenAI chat API; an image (Best-Stack Perception via
99
+ Omni) rides as a data-URL image_url content part."""
100
+ content: str | list = prompt
101
+ if image_path:
102
+ suffix = Path(image_path).suffix.lstrip(".").lower() or "png"
103
+ content = [
104
+ {"type": "text", "text": prompt},
105
+ {
106
+ "type": "image_url",
107
+ "image_url": {"url": f"data:image/{suffix};base64,{_b64(image_path)}"},
108
+ },
109
+ ]
110
+ body = {
111
+ "model": self._served_model,
112
+ "messages": [{"role": "user", "content": content}],
113
+ "stream": False,
114
+ }
115
+ data = self._post("/v1/chat/completions", body)
116
+ return (data.get("choices") or [{}])[0].get("message", {}).get("content", "") or ""
117
+
118
+ def transcribe(self, audio_path: str) -> str:
119
+ """Transcribe a voice note (Best-Stack Audio via Omni): OpenAI input_audio
120
+ content in, plain transcript out. Same contract as AudioModel.transcribe."""
121
+ fmt = Path(audio_path).suffix.lstrip(".").lower() or "wav"
122
+ body = {
123
+ "model": self._served_model,
124
+ "messages": [
125
+ {
126
+ "role": "user",
127
+ "content": [
128
+ {
129
+ "type": "input_audio",
130
+ "input_audio": {"data": _b64(audio_path), "format": fmt},
131
+ },
132
+ {"type": "text", "text": "Transcribe this voice note verbatim."},
133
+ ],
134
+ }
135
+ ],
136
+ "stream": False,
137
+ }
138
+ data = self._post("/v1/chat/completions", body)
139
+ return (data.get("choices") or [{}])[0].get("message", {}).get("content", "") or ""
140
+
141
+
142
+ def _b64(path: str) -> str:
143
+ with open(path, "rb") as fh:
144
+ return base64.b64encode(fh.read()).decode("ascii")
145
+
146
+
147
+ def _adapt_tool_call(tc: dict) -> dict:
148
+ """OpenAI tool_call -> our shape. `arguments` arrives as a JSON string; parse it."""
149
+ fn = tc.get("function", {}) or {}
150
+ args = fn.get("arguments", {})
151
+ if isinstance(args, str):
152
+ try:
153
+ args = json.loads(args) if args.strip() else {}
154
+ except json.JSONDecodeError:
155
+ args = {}
156
+ return {"function": {"name": fn.get("name", ""), "arguments": args}}
157
+
158
+
159
+ def _to_openai_messages(messages: list[dict]) -> list[dict]:
160
+ """Sanitize our internal message list into valid OpenAI chat format.
161
+
162
+ vLLM's OpenAI endpoint is stricter than Ollama, which our brain_loop targets:
163
+ - assistant tool_calls need a string `arguments` (we carry a dict) + an `id`;
164
+ - each `tool` reply needs a `tool_call_id` matching its assistant tool_call.
165
+ We assign deterministic ids and thread them to the following tool messages, so
166
+ brain_loop.py / the Ollama path stay untouched.
167
+ """
168
+ out: list[dict] = []
169
+ pending_ids: list[str] = [] # tool_call ids awaiting their tool replies, in order
170
+ counter = 0
171
+
172
+ for m in messages:
173
+ role = m.get("role")
174
+ # A message carrying tool_calls is an assistant turn — even if it has no
175
+ # `role` (our chat() return value is appended verbatim by brain_loop and
176
+ # lacks one). vLLM requires role + string args + ids; normalize all of it.
177
+ if m.get("tool_calls"):
178
+ calls = []
179
+ for tc in m["tool_calls"]:
180
+ fn = tc.get("function", {}) or {}
181
+ args = fn.get("arguments", {})
182
+ if not isinstance(args, str):
183
+ args = json.dumps(args)
184
+ tc_id = tc.get("id") or f"call_{counter}"
185
+ counter += 1
186
+ pending_ids.append(tc_id)
187
+ calls.append(
188
+ {
189
+ "id": tc_id,
190
+ "type": "function",
191
+ "function": {"name": fn.get("name", ""), "arguments": args},
192
+ }
193
+ )
194
+ out.append(
195
+ {"role": "assistant", "content": m.get("content") or None, "tool_calls": calls}
196
+ )
197
+ elif role == "tool":
198
+ tc_id = m.get("tool_call_id") or (pending_ids.pop(0) if pending_ids else "call_0")
199
+ out.append({"role": "tool", "tool_call_id": tc_id, "content": m.get("content", "")})
200
+ else:
201
+ out.append(m)
202
+ return out
quillwright/backends/modal_app.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal deployment of the Quillwright Best-Stack brain (ADR-0009).
2
+
3
+ ADR-0005: the hosted HF Space has no GPU, so real models reach it via an outbound
4
+ HTTPS call to Modal. ADR-0009 locks the **Best Stack** brain as **Nemotron 3 Nano
5
+ 30B-A3B** (31.6B total / 3.2B active, MoE) — genuinely better than the local 4B and
6
+ too big for local Ollama, which is exactly why it lives on Modal.
7
+
8
+ Served with **vLLM** using NVIDIA's documented FP8 + tool-calling recipe
9
+ (https://docs.vllm.ai/projects/recipes/en/latest/NVIDIA/Nemotron-3-Nano-30B-A3B.html).
10
+ vLLM exposes an OpenAI-compatible API, so the client (`backends/modal.py`) talks
11
+ /v1/chat/completions and adapts the tool_calls shape back to our contract.
12
+
13
+ De-risk scope (ADR-0005): the BRAIN only. Vision (Omni) + multilingual copy this
14
+ pattern once it's proven.
15
+
16
+ Deploy (you run these — they touch your Modal account + credits):
17
+ modal setup # one-time auth
18
+ modal deploy quillwright/backends/modal_app.py
19
+ Then point the app at the printed URL:
20
+ export FF_BACKEND=modal
21
+ export FF_MODAL_BRAIN_URL="https://<...>.modal.run"
22
+ """
23
+
24
+ import modal
25
+
26
+ MODEL = "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-FP8" # ADR-0009 Best-Stack brain, FP8 (~32GB).
27
+ VLLM_PORT = 8000
28
+ image = (
29
+ # CUDA *devel* base (includes nvcc): FlashInfer's FP8 MoE kernel JIT-compiles at
30
+ # runtime, so debian_slim (no nvcc) crashes engine init. This matches torch's cu12.
31
+ modal.Image.from_registry("nvidia/cuda:12.8.1-devel-ubuntu22.04", add_python="3.12")
32
+ .pip_install("vllm==0.12.0", "huggingface_hub", "flashinfer-python")
33
+ # Fetch NVIDIA's custom reasoning-parser plugin via huggingface_hub.
34
+ .run_commands(
35
+ 'python -c "'
36
+ "from huggingface_hub import hf_hub_download; import shutil; "
37
+ "p = hf_hub_download("
38
+ "repo_id='nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16', "
39
+ "filename='nano_v3_reasoning_parser.py'); "
40
+ "shutil.copy(p, '/root/nano_v3_reasoning_parser.py')\""
41
+ )
42
+ .env(
43
+ {
44
+ # FP8 MoE acceleration (FP8 variant only), per NVIDIA's recipe.
45
+ "VLLM_USE_FLASHINFER_MOE_FP8": "1",
46
+ "VLLM_FLASHINFER_MOE_BACKEND": "throughput",
47
+ # Download weights into the mounted cache volume (NOT ~/.cache, which the
48
+ # build populates — Modal refuses to mount a volume over a non-empty dir).
49
+ "HF_HOME": "/cache",
50
+ }
51
+ )
52
+ )
53
+
54
+ # Cache the downloaded weights across cold starts (pull once, not every boot).
55
+ hf_cache = modal.Volume.from_name("quillwright-hf-cache", create_if_missing=True)
56
+
57
+ app = modal.App("quillwright-brain")
58
+
59
+
60
+ @app.function(
61
+ image=image,
62
+ gpu="L40S", # FP8 needs ~32GB VRAM; L40S has 48GB (A10G's 24GB is too small).
63
+ volumes={"/cache": hf_cache}, # clean mount point; HF_HOME points here.
64
+ # HF_TOKEN for the weight download (harmless if ungated; required if the NVIDIA
65
+ # repo is gated). Same secret across all four apps.
66
+ secrets=[modal.Secret.from_name("huggingface-secret")],
67
+ timeout=1200,
68
+ scaledown_window=120, # warm 2 min after a request (masks cold starts; limits idle L40S burn).
69
+ min_containers=0, # true scale-to-zero: $0 when idle (open-ended judging window — never pre-warm-and-forget).
70
+ )
71
+ @modal.concurrent(max_inputs=8)
72
+ @modal.web_server(port=VLLM_PORT, startup_timeout=900)
73
+ def serve():
74
+ """Launch vLLM's OpenAI-compatible server with NVIDIA's tool-calling recipe."""
75
+ import subprocess
76
+
77
+ cmd = [
78
+ "vllm",
79
+ "serve",
80
+ MODEL,
81
+ "--trust-remote-code",
82
+ "--async-scheduling",
83
+ "--kv-cache-dtype",
84
+ "fp8",
85
+ "--tensor-parallel-size",
86
+ "1",
87
+ "--enable-auto-tool-choice",
88
+ "--tool-call-parser",
89
+ "qwen3_coder", # the parser NVIDIA specifies for this model.
90
+ "--reasoning-parser-plugin",
91
+ "/root/nano_v3_reasoning_parser.py",
92
+ "--reasoning-parser",
93
+ "nano_v3",
94
+ "--max-model-len",
95
+ "262144",
96
+ "--max-num-seqs",
97
+ "8",
98
+ "--port",
99
+ str(VLLM_PORT),
100
+ "--host",
101
+ "0.0.0.0",
102
+ ]
103
+ subprocess.Popen(" ".join(cmd), shell=True)
quillwright/backends/modal_aya_app.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal deployment of the Quillwright Best-Stack Multilingual: Aya Expanse 8B.
2
+
3
+ ADR-0009 lists Best-Stack Multilingual as "Aya larger / Command". The pick here is
4
+ CohereLabs/aya-expanse-8b in full BF16: a *newer generation* than the local Aya 23
5
+ (q4 via Ollama) — better multilingual quality at full precision, and small enough
6
+ for a cheap A10G (24 GB). Deliberately NOT aya-expanse-32b: ADR-0009 records the
7
+ contest rule as STRICTLY under 32B per model ("verified at kickoff"), which a
8
+ 32B-named model fails. If that reading is overturned (PROGRESS/CONTEXT say "<=32B"),
9
+ upgrading is this file's MODEL constant + FF_MODAL_AYA_MODEL — one line.
10
+
11
+ Translation only needs .generate() (descriptions in, descriptions out — numbers
12
+ never pass through a model), so this is the simplest of the three vLLM apps: no
13
+ tool parsing, no reasoning parser, no FP8 MoE kernels (plain dense 8B).
14
+
15
+ NOT deployed yet (build-only; deploys touch your Modal account + credits):
16
+ modal deploy quillwright/backends/modal_aya_app.py
17
+ Then point the app at the printed URL (per-role opt-in):
18
+ export FF_BACKEND=modal
19
+ export FF_MODAL_AYA_URL="https://<...>.modal.run"
20
+ """
21
+
22
+ import modal
23
+
24
+ MODEL = "CohereLabs/aya-expanse-8b" # ADR-0009 Best-Stack Multilingual (see above).
25
+ VLLM_PORT = 8000
26
+ image = (
27
+ # No FP8-MoE JIT here (dense BF16 model) — the slim base is enough.
28
+ modal.Image.debian_slim(python_version="3.12")
29
+ .pip_install("vllm==0.12.0", "huggingface_hub")
30
+ .env({"HF_HOME": "/cache"})
31
+ )
32
+
33
+ # Shared with the other apps: pull weights once, not every cold start.
34
+ hf_cache = modal.Volume.from_name("quillwright-hf-cache", create_if_missing=True)
35
+
36
+ app = modal.App("quillwright-aya")
37
+
38
+
39
+ @app.function(
40
+ image=image,
41
+ gpu="A10G", # 8B BF16 ~16GB; A10G's 24GB fits with short-context headroom.
42
+ volumes={"/cache": hf_cache},
43
+ # aya-expanse-8b is a GATED HF repo — vLLM 401s without a token. HF_TOKEN from
44
+ # this secret authenticates the weight download. Accept the license on the repo
45
+ # page first: huggingface.co/CohereLabs/aya-expanse-8b
46
+ secrets=[modal.Secret.from_name("huggingface-secret")],
47
+ timeout=1200,
48
+ scaledown_window=300, # stay warm 5 min after a request to mask cold starts (cheap A10G — idle burn is low).
49
+ min_containers=0, # true scale-to-zero: $0 when idle (open-ended judging window — never pre-warm-and-forget).
50
+ )
51
+ @modal.concurrent(max_inputs=8)
52
+ @modal.web_server(port=VLLM_PORT, startup_timeout=900)
53
+ def serve():
54
+ """Launch vLLM's OpenAI-compatible server for translation calls."""
55
+ import subprocess
56
+
57
+ cmd = [
58
+ "vllm",
59
+ "serve",
60
+ MODEL,
61
+ # Estimate descriptions are short; a small context keeps VRAM comfortable.
62
+ "--max-model-len",
63
+ "8192",
64
+ "--max-num-seqs",
65
+ "8",
66
+ "--tensor-parallel-size",
67
+ "1",
68
+ "--port",
69
+ str(VLLM_PORT),
70
+ "--host",
71
+ "0.0.0.0",
72
+ ]
73
+ subprocess.Popen(" ".join(cmd), shell=True)
quillwright/backends/modal_omni_app.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal deployment of the Quillwright Best-Stack Perception + Audio: Nemotron Omni.
2
+
3
+ ADR-0009: Omni (nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning, 31B total / 3B active
4
+ MoE) is the selectable Best-Stack *alternative* for Perception — MiniCPM-V stays the
5
+ Private-Stack default (protects the OpenBMB track). Because Omni is omnimodal
6
+ (image + audio + text), this ONE deployment also serves the Best-Stack Audio role:
7
+ the client (`backends/modal.py`) sends photos as image_url parts and voice notes as
8
+ input_audio parts to the same /v1/chat/completions endpoint.
9
+
10
+ Served with vLLM (>=0.20 per NVIDIA's Omni recipe,
11
+ https://recipes.vllm.ai/nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-BF16) in the
12
+ FP8 variant (32.8 GB — same L40S class the brain app proved; encoders stay BF16).
13
+
14
+ NOT deployed yet (build-only; deploys touch your Modal account + credits):
15
+ modal deploy quillwright/backends/modal_omni_app.py
16
+ Then point the app at the printed URL (per-role opt-in — the brain URL stays separate):
17
+ export FF_BACKEND=modal
18
+ export FF_MODAL_OMNI_URL="https://<...>.modal.run"
19
+
20
+ Deploy-time caveats (verify on first run, cheaply, ONE request at a time):
21
+ - VRAM: 32.8 GB weights + BF16 encoders on a 48 GB L40S is tighter than the brain;
22
+ --max-model-len is kept small (32K) for headroom. If engine init OOMs, bump to
23
+ gpu="A100-80GB" for the verification run only.
24
+ - The browser records voice notes as webm; Omni's recipe lists wav/mp3. The local
25
+ transformers path handles webm today — if Omni rejects it, transcode to wav in
26
+ /api/transcribe before the Modal call (do not silently drop audio).
27
+ """
28
+
29
+ import modal
30
+
31
+ MODEL = "nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-FP8" # ADR-0009 Best-Stack Omni.
32
+ VLLM_PORT = 8000
33
+ image = (
34
+ # CUDA devel base (nvcc) for FlashInfer's FP8 MoE JIT — same backbone/arch lesson
35
+ # as the brain app (debian_slim crashes engine init).
36
+ modal.Image.from_registry("nvidia/cuda:12.8.1-devel-ubuntu22.04", add_python="3.12")
37
+ # vllm[audio] pulls the audio decoders the Omni recipe requires.
38
+ .pip_install("vllm[audio]==0.20.0", "huggingface_hub", "flashinfer-python")
39
+ .env(
40
+ {
41
+ "VLLM_USE_FLASHINFER_MOE_FP8": "1",
42
+ "VLLM_FLASHINFER_MOE_BACKEND": "throughput",
43
+ # Weights go to the mounted cache volume (NOT ~/.cache — Modal refuses to
44
+ # mount a volume over a non-empty dir).
45
+ "HF_HOME": "/cache",
46
+ }
47
+ )
48
+ )
49
+
50
+ # Shared with the brain app: pull weights once, not every cold start.
51
+ hf_cache = modal.Volume.from_name("quillwright-hf-cache", create_if_missing=True)
52
+
53
+ app = modal.App("quillwright-omni")
54
+
55
+
56
+ @app.function(
57
+ image=image,
58
+ gpu="L40S", # FP8 weights 32.8GB; encoders BF16. 48GB with a small context fits.
59
+ volumes={"/cache": hf_cache},
60
+ # HF_TOKEN for the weight download (harmless if ungated; required if the NVIDIA
61
+ # repo is gated). Same secret across all four apps.
62
+ secrets=[modal.Secret.from_name("huggingface-secret")],
63
+ timeout=1200,
64
+ scaledown_window=120, # warm 2 min after a request (masks cold starts; limits idle L40S burn).
65
+ min_containers=0, # true scale-to-zero: $0 when idle (open-ended judging window — never pre-warm-and-forget).
66
+ )
67
+ @modal.concurrent(max_inputs=8)
68
+ @modal.web_server(port=VLLM_PORT, startup_timeout=900)
69
+ def serve():
70
+ """Launch vLLM's OpenAI-compatible server with NVIDIA's Omni recipe."""
71
+ import subprocess
72
+
73
+ cmd = [
74
+ "vllm",
75
+ "serve",
76
+ MODEL,
77
+ "--trust-remote-code",
78
+ # One photo or one short voice note per request — not the recipe's video load.
79
+ "--max-model-len",
80
+ "32768",
81
+ "--max-num-seqs",
82
+ "8",
83
+ # The cmd is joined into ONE shell string (shell=True, like the brain app):
84
+ # single-quote the JSON and keep it space-free so it stays one shell token.
85
+ "--limit-mm-per-prompt",
86
+ '\'{"image":4,"audio":1,"video":0}\'',
87
+ "--kv-cache-dtype",
88
+ "fp8",
89
+ "--tensor-parallel-size",
90
+ "1",
91
+ "--enable-auto-tool-choice",
92
+ "--tool-call-parser",
93
+ "qwen3_coder",
94
+ "--reasoning-parser",
95
+ "nemotron_v3", # built into vLLM >=0.20 (no plugin file, unlike the brain app).
96
+ "--port",
97
+ str(VLLM_PORT),
98
+ "--host",
99
+ "0.0.0.0",
100
+ ]
101
+ subprocess.Popen(" ".join(cmd), shell=True)
quillwright/backends/modal_parse_app.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Modal deployment of Nemotron Parse — Document Capture extraction (ADR-0011).
2
+
3
+ Nemotron Parse is the Extraction Model Role (ADR-0009): a document image → structured
4
+ text + tables. Local de-risk on Apple Silicon FAILED (>30GB RAM, 5+ min/doc — the
5
+ C-RADIO encoder + mBART decoder thrash without a GPU), so it runs on Modal instead.
6
+
7
+ Unlike the brain (text, vLLM OpenAI API), Parse is VISUAL — there is no standard
8
+ "parse image → structured output" OpenAI route, so this exposes a CUSTOM endpoint:
9
+ POST a base64 image, get back the parsed blocks.
10
+
11
+ The model's raw output is a token-encoded string of (bbox, text, class) triples, e.g.
12
+ `<x_120><y_45>Dual run capacitor<x_980><y_70><class_Table>`. Two repo-shipped files
13
+ turn that into structured blocks: `postprocessing.py` (extract_classes_bboxes,
14
+ transform_bbox_to_original, postprocess_text) and `latex2html.py` (table conversion).
15
+ They are NOT loaded by trust_remote_code — we download them with hf_hub_download and
16
+ import them. (Verified against the v1.2 model card, 2026-06-11; see ADR-0011.)
17
+
18
+ This endpoint runs the full postprocessing server-side and returns clean blocks
19
+ [{class, bbox, text}], so the on-device client (backends/parse.py) stays light.
20
+
21
+ Cost stance (ADR-0011): proven as a demo capability — NOT wired to the live Space
22
+ (no continuous spend). Parse is ~1GB, so a cheap T4 is plenty (no L40S needed).
23
+
24
+ Deploy:
25
+ modal deploy quillwright/backends/modal_parse_app.py
26
+ export FF_MODAL_PARSE_URL="https://<...>.modal.run"
27
+ """
28
+
29
+ import modal
30
+
31
+ MODEL = "nvidia/NVIDIA-Nemotron-Parse-v1.2"
32
+
33
+ # Task prompt from the v1.2 model card — the FULL four-token form. v1.1's three-token
34
+ # prompt produces "significantly degraded" results on v1.2, per the card.
35
+ TASK_PROMPT = "</s><s><predict_bbox><predict_classes><output_markdown><predict_no_text_in_pic>"
36
+
37
+ # Repo-shipped postprocessing files (standalone modules, NOT trust_remote_code).
38
+ POSTPROC_FILES = ("postprocessing.py", "latex2html.py")
39
+
40
+ image = (
41
+ modal.Image.debian_slim(python_version="3.12")
42
+ .pip_install(
43
+ # Pins from the v1.2 model card — looser versions risk the C-RADIO/mBART
44
+ # custom code breaking on a transformers API change.
45
+ "torch",
46
+ "transformers==5.6.1",
47
+ "accelerate==1.12.0",
48
+ "timm==1.0.22",
49
+ "albumentations==2.0.8",
50
+ # latex2html.py needs BeautifulSoup for table HTML conversion.
51
+ "beautifulsoup4",
52
+ "huggingface_hub",
53
+ "pillow",
54
+ "fastapi[standard]",
55
+ )
56
+ .env({"HF_HOME": "/cache"})
57
+ )
58
+
59
+ hf_cache = modal.Volume.from_name("quillwright-hf-cache", create_if_missing=True)
60
+
61
+ app = modal.App("quillwright-parse")
62
+
63
+
64
+ @app.cls(
65
+ image=image,
66
+ gpu="T4", # Parse is ~1GB; a T4 is plenty (and the cheapest GPU).
67
+ volumes={"/cache": hf_cache},
68
+ # HF_TOKEN for the weight download (harmless if ungated; required if the NVIDIA
69
+ # repo is gated). Same secret across all four apps.
70
+ secrets=[modal.Secret.from_name("huggingface-secret")],
71
+ timeout=600,
72
+ scaledown_window=240, # cheap T4 — modest idle burn, kept warm a bit longer for multi-doc capture.
73
+ min_containers=0, # true scale-to-zero: $0 when idle (open-ended judging window — never pre-warm-and-forget).
74
+ )
75
+ @modal.concurrent(max_inputs=4)
76
+ class Parser:
77
+ @modal.enter()
78
+ def load(self):
79
+ """Load the model + its repo-shipped postprocessing once per container."""
80
+ import importlib.util
81
+ import sys
82
+
83
+ import torch
84
+ from huggingface_hub import hf_hub_download
85
+ from transformers import AutoModel, AutoProcessor, GenerationConfig
86
+
87
+ # Pull postprocessing.py + latex2html.py from the model repo and put their dir
88
+ # on sys.path (postprocessing imports latex2html by name, so both must resolve).
89
+ for fname in POSTPROC_FILES:
90
+ module_dir = hf_hub_download(MODEL, fname).rsplit("/", 1)[0]
91
+ if module_dir not in sys.path:
92
+ sys.path.insert(0, module_dir)
93
+ spec = importlib.util.spec_from_file_location(
94
+ "nemotron_postprocessing", hf_hub_download(MODEL, "postprocessing.py")
95
+ )
96
+ self.pp = importlib.util.module_from_spec(spec)
97
+ spec.loader.exec_module(self.pp)
98
+
99
+ self.model = (
100
+ AutoModel.from_pretrained(MODEL, trust_remote_code=True, dtype=torch.bfloat16)
101
+ .to("cuda")
102
+ .eval()
103
+ )
104
+ self.processor = AutoProcessor.from_pretrained(MODEL, trust_remote_code=True)
105
+ self.gen_config = GenerationConfig.from_pretrained(MODEL, trust_remote_code=True)
106
+
107
+ @modal.fastapi_endpoint(method="POST")
108
+ def parse(self, payload: dict):
109
+ """POST {image: base64} -> {blocks: [{class, bbox, text}], raw: <str>}.
110
+
111
+ Runs the full repo postprocessing server-side: decode → extract triples →
112
+ rescale bboxes to the original image → format text (tables as markdown).
113
+ """
114
+ import base64
115
+ import io
116
+
117
+ from PIL import Image
118
+
119
+ data = payload.get("image", "")
120
+ if "," in data and data.strip().startswith("data:"):
121
+ data = data.split(",", 1)[1]
122
+ img = Image.open(io.BytesIO(base64.b64decode(data))).convert("RGB")
123
+
124
+ inputs = self.processor(
125
+ images=[img], text=TASK_PROMPT, return_tensors="pt", add_special_tokens=False
126
+ ).to("cuda")
127
+ outputs = self.model.generate(**inputs, generation_config=self.gen_config)
128
+ raw = self.processor.batch_decode(outputs, skip_special_tokens=True)[0]
129
+
130
+ classes, bboxes, texts = self.pp.extract_classes_bboxes(raw)
131
+ bboxes = [self.pp.transform_bbox_to_original(b, img.width, img.height) for b in bboxes]
132
+ texts = [
133
+ self.pp.postprocess_text(t, cls=c, table_format="markdown", text_format="markdown")
134
+ for t, c in zip(texts, classes)
135
+ ]
136
+ blocks = [
137
+ {"class": c, "bbox": list(b), "text": t} for c, b, t in zip(classes, bboxes, texts)
138
+ ]
139
+ return {"blocks": blocks, "raw": raw}
quillwright/backends/parse.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ParseModel: the Document Capture client (ADR-0011), backed by Nemotron Parse on Modal.
2
+
3
+ Parse is the Extraction Model Role (ADR-0009). It is VISUAL, so it does not fit the
4
+ vLLM OpenAI route the brain uses — it has its own Modal endpoint (modal_parse_app.py)
5
+ that POSTs a base64 image and returns structured blocks [{class, bbox, text}], having
6
+ already run the model's repo-shipped postprocessing server-side.
7
+
8
+ This client is the on-device half: it turns those blocks into the two things the
9
+ Estimate pipeline understands, per ADR-0011 decision C —
10
+
11
+ - a priced table row -> ProposedLineItem (human confirms the price via Agent Pause)
12
+ - everything else -> Observation(kind="text") (flows straight through)
13
+
14
+ The document is the *source*, but any price it read is *proposed*, never a fact: the
15
+ human gates every customer-facing number (Facts-from-Tools, ADR-0004).
16
+
17
+ The base URL (printed by `modal deploy modal_parse_app.py`) comes from FF_MODAL_PARSE_URL.
18
+ """
19
+
20
+ import base64
21
+ import os
22
+ import re
23
+
24
+ import requests
25
+
26
+ from quillwright.models import Observation, ProposedLineItem
27
+
28
+ # Parse's table content arrives as markdown. A money cell looks like "$42.50",
29
+ # "$1,250.00", or "42.50" — capture the numeric value, tolerating $ and thousands commas.
30
+ _MONEY = re.compile(r"\$?\s*([\d,]+\.\d{1,2}|\d[\d,]*)")
31
+ # A leading integer/decimal in a cell is the quantity ("2", "4", "1.5").
32
+ _QTY = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*$")
33
+
34
+
35
+ class ParseModel:
36
+ name = "nemotron-parse-v1.2"
37
+
38
+ def __init__(self, base_url: str | None = None, timeout: float = 180.0):
39
+ self._base = (base_url or os.environ.get("FF_MODAL_PARSE_URL", "")).rstrip("/")
40
+ if not self._base:
41
+ raise RuntimeError(
42
+ "FF_MODAL_PARSE_URL is not set — deploy modal_parse_app.py and export "
43
+ "the URL it prints (see backends/modal_parse_app.py)."
44
+ )
45
+ self._timeout = timeout
46
+
47
+ def parse_document(self, image_path: str) -> tuple[list[Observation], list[ProposedLineItem]]:
48
+ """Read a document image; return (observations, proposed_line_items)."""
49
+ with open(image_path, "rb") as fh:
50
+ b64 = base64.b64encode(fh.read()).decode("ascii")
51
+ resp = requests.post(f"{self._base}/parse", json={"image": b64}, timeout=self._timeout)
52
+ resp.raise_for_status()
53
+ blocks = resp.json().get("blocks", [])
54
+ return blocks_to_pipeline(blocks)
55
+
56
+
57
+ def blocks_to_pipeline(
58
+ blocks: list[dict],
59
+ ) -> tuple[list[Observation], list[ProposedLineItem]]:
60
+ """Split Parse blocks into Observations + Proposed Line Items (ADR-0011).
61
+
62
+ Pure function (no network) so it can be unit-tested against real Parse output.
63
+ Table blocks become priced ProposedLineItems where a price is present; their
64
+ non-priced rows and every non-table block become text Observations.
65
+ """
66
+ observations: list[Observation] = []
67
+ proposed: list[ProposedLineItem] = []
68
+
69
+ for block in blocks:
70
+ cls = block.get("class", "")
71
+ text = (block.get("text") or "").strip()
72
+ if not text:
73
+ continue
74
+ if cls == "Table":
75
+ rows_obs, rows_items = _table_to_items(text)
76
+ observations.extend(rows_obs)
77
+ proposed.extend(rows_items)
78
+ else:
79
+ observations.append(Observation(kind="text", text=text))
80
+ return observations, proposed
81
+
82
+
83
+ def _table_to_items(
84
+ table_md: str,
85
+ ) -> tuple[list[Observation], list[ProposedLineItem]]:
86
+ """Parse a markdown table into priced line items, gating on a real price.
87
+
88
+ A row with a money cell -> ProposedLineItem (description = its longest text
89
+ cell, quantity from a bare-number cell if present, rate from the price).
90
+ A row with no price falls back to an Observation so nothing is silently dropped.
91
+ """
92
+ observations: list[Observation] = []
93
+ proposed: list[ProposedLineItem] = []
94
+
95
+ for line in table_md.splitlines():
96
+ line = line.strip()
97
+ if not line or set(line) <= {"|", "-", " ", ":"}:
98
+ continue # blank or the header separator row (|---|---|)
99
+ cells = [c.strip() for c in line.strip("|").split("|")]
100
+ cells = [c for c in cells if c != ""]
101
+ if not cells:
102
+ continue
103
+
104
+ price = _row_price(cells)
105
+ if price is None:
106
+ observations.append(Observation(kind="text", text=" ".join(cells)))
107
+ continue
108
+
109
+ # Skip a header row that happens to contain the literal word "price" but no
110
+ # numeric description (e.g. "| Item | Qty | Price |" has no money cell, so it
111
+ # already fell through above — this guards a row that is only labels).
112
+ description = _row_description(cells)
113
+ if not description:
114
+ observations.append(Observation(kind="text", text=" ".join(cells)))
115
+ continue
116
+
117
+ proposed.append(
118
+ ProposedLineItem(
119
+ description=description,
120
+ quantity=_row_quantity(cells),
121
+ rate=price,
122
+ source_text=" ".join(cells),
123
+ )
124
+ )
125
+ return observations, proposed
126
+
127
+
128
+ def _row_price(cells: list[str]) -> float | None:
129
+ """The price of a row = the money value in its last cell that has one.
130
+
131
+ Scanning right-to-left picks the line *total* / unit price over an earlier
132
+ quantity that also matches the number pattern.
133
+ """
134
+ for cell in reversed(cells):
135
+ if "$" in cell or "." in cell:
136
+ m = _MONEY.search(cell)
137
+ if m:
138
+ return float(m.group(1).replace(",", ""))
139
+ return None
140
+
141
+
142
+ def _row_quantity(cells: list[str]) -> float:
143
+ """A standalone integer/decimal cell is the quantity; default 1."""
144
+ for cell in cells:
145
+ m = _QTY.match(cell)
146
+ if m:
147
+ return float(m.group(1))
148
+ return 1.0
149
+
150
+
151
+ def _row_description(cells: list[str]) -> str:
152
+ """The description is the longest cell that is neither a price nor a bare number."""
153
+ candidates = [
154
+ c for c in cells if not _QTY.match(c) and "$" not in c and not _MONEY.fullmatch(c)
155
+ ]
156
+ return max(candidates, key=len) if candidates else ""
quillwright/brain_loop.py CHANGED
@@ -38,6 +38,9 @@ def run_brain(
38
  """Drive the model to build line items. Returns (line_items, trace, pause-or-None)."""
39
  line_items: list[LineItem] = []
40
  trace: list[TraceStep] = []
 
 
 
41
  messages = [
42
  {"role": "system", "content": SYSTEM},
43
  {
@@ -65,7 +68,9 @@ def run_brain(
65
  return line_items, trace, {"item": result["item"]}
66
  if result["status"] == "done":
67
  done = True
68
- trace.append(TraceStep(action="finish", model="brain", detail="estimate complete"))
 
 
69
  break
70
  if result["status"] == "added":
71
  line_items.append(result["line_item"])
@@ -73,7 +78,7 @@ def run_brain(
73
  trace.append(
74
  TraceStep(
75
  action="add_priced_item",
76
- model="brain",
77
  detail=f"{li.quantity:g} x {li.description} -> {li.subtotal}",
78
  )
79
  )
 
38
  """Drive the model to build line items. Returns (line_items, trace, pause-or-None)."""
39
  line_items: list[LineItem] = []
40
  trace: list[TraceStep] = []
41
+ # The actual model name (e.g. "nemotron-3-nano:4b" or "StubModel") so the trace
42
+ # truthfully shows which model answered — no guessing whether a model was hit.
43
+ brain_name = getattr(model, "name", "brain")
44
  messages = [
45
  {"role": "system", "content": SYSTEM},
46
  {
 
68
  return line_items, trace, {"item": result["item"]}
69
  if result["status"] == "done":
70
  done = True
71
+ trace.append(
72
+ TraceStep(action="finish", model=brain_name, detail="estimate complete")
73
+ )
74
  break
75
  if result["status"] == "added":
76
  line_items.append(result["line_item"])
 
78
  trace.append(
79
  TraceStep(
80
  action="add_priced_item",
81
+ model=brain_name,
82
  detail=f"{li.quantity:g} x {li.description} -> {li.subtotal}",
83
  )
84
  )
quillwright/estimate_store.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """EstimateStore: per-Account persistence of Saved Estimates + Refinement Threads
2
+ (ADR-0013).
3
+
4
+ JSON-on-disk behind a small, swappable interface (save / list / load / delete),
5
+ keyed by `account_id`. Separate from Episodic Memory (memory.py), which stays a
6
+ pure append-only Recall corpus. One file per estimate at
7
+ `<path>/<account_id>/<id>.json`. Ids are zero-padded sequence numbers (deterministic
8
+ and offline-friendly, like Memory's sequence ids — no uuid/wall-clock).
9
+
10
+ Durable locally; on the hosted Space `path` points at a per-session temp dir so
11
+ visitors never see each other's data (the Space is one container / one account).
12
+ """
13
+
14
+ import json
15
+ import os
16
+
17
+ ACCOUNT_ID = os.environ.get("FF_ACCOUNT_ID", "demo")
18
+ STORE_PATH = os.environ.get("FF_ESTIMATE_STORE", "/tmp/quillwright_estimates")
19
+
20
+
21
+ class EstimateStore:
22
+ def __init__(self, path: str | None = None, account_id: str | None = None):
23
+ # Read the env at construction time (not import time) so a test/launch that
24
+ # sets FF_ESTIMATE_STORE / FF_ACCOUNT_ID before building the store wins.
25
+ path = path or os.environ.get("FF_ESTIMATE_STORE", "/tmp/quillwright_estimates")
26
+ account_id = account_id or os.environ.get("FF_ACCOUNT_ID", "demo")
27
+ self._dir = os.path.join(path, account_id)
28
+ self._account_id = account_id
29
+
30
+ def _ensure_dir(self) -> None:
31
+ os.makedirs(self._dir, exist_ok=True)
32
+
33
+ def _path(self, id: str) -> str:
34
+ return os.path.join(self._dir, f"{id}.json")
35
+
36
+ def _next_id(self) -> str:
37
+ if not os.path.isdir(self._dir):
38
+ return "0001"
39
+ existing = [f[:-5] for f in os.listdir(self._dir) if f.endswith(".json")]
40
+ nums = [int(e) for e in existing if e.isdigit()]
41
+ return f"{(max(nums) + 1 if nums else 1):04d}"
42
+
43
+ def save(self, estimate: dict, thread: list[dict], id: str | None = None) -> dict:
44
+ """Create (id=None) or update-in-place (id given) a Saved Estimate."""
45
+ self._ensure_dir()
46
+ if id is None:
47
+ id = self._next_id()
48
+ rec = {
49
+ "id": id,
50
+ "account_id": self._account_id,
51
+ "estimate": estimate,
52
+ "thread": thread,
53
+ "saved_seq": int(id),
54
+ }
55
+ with open(self._path(id), "w") as f:
56
+ json.dump(rec, f, indent=2)
57
+ return rec
58
+
59
+ def load(self, id: str) -> dict | None:
60
+ path = self._path(id)
61
+ if not os.path.isfile(path):
62
+ return None
63
+ with open(path) as f:
64
+ return json.load(f)
65
+
66
+ def list_estimates(self) -> list[dict]:
67
+ """Saved estimates newest-first, each a list-row summary (id + title + total)."""
68
+ if not os.path.isdir(self._dir):
69
+ return []
70
+ recs = []
71
+ for fname in os.listdir(self._dir):
72
+ if not fname.endswith(".json"):
73
+ continue
74
+ with open(os.path.join(self._dir, fname)) as f:
75
+ rec = json.load(f)
76
+ est = rec.get("estimate", {})
77
+ recs.append(
78
+ {
79
+ "id": rec["id"],
80
+ "job_title": est.get("job_title", "Estimate"),
81
+ "total": est.get("total"),
82
+ "saved_seq": rec.get("saved_seq", 0),
83
+ }
84
+ )
85
+ recs.sort(key=lambda r: r["saved_seq"], reverse=True)
86
+ return recs
87
+
88
+ def delete(self, id: str) -> None:
89
+ path = self._path(id)
90
+ if os.path.isfile(path):
91
+ os.remove(path)
quillwright/memory.py CHANGED
@@ -10,8 +10,12 @@ from collections import Counter
10
 
11
 
12
  class Memory:
13
- def __init__(self, path: str):
 
 
 
14
  self._path = path
 
15
  self._runs: list[dict] = []
16
  self._load()
17
 
@@ -20,22 +24,51 @@ class Memory:
20
  with open(self._path) as f:
21
  self._runs = json.load(f).get("runs", [])
22
 
23
- def record_run(self, transcript: str, line_items: list[str]) -> None:
24
- self._runs.append({"transcript": transcript, "line_items": list(line_items)})
 
 
 
 
 
 
25
  self._save()
26
 
 
 
 
 
 
 
 
 
 
 
27
  def _save(self) -> None:
28
  os.makedirs(os.path.dirname(self._path) or ".", exist_ok=True)
29
  with open(self._path, "w") as f:
30
  json.dump({"runs": self._runs}, f, indent=2)
31
 
32
  def recall(self, query: str) -> list[dict]:
 
 
33
  q = query.strip().lower()
34
  scored = [(self._haystack(r).count(q), r) for r in self._runs]
35
  matches = [(score, r) for score, r in scored if score > 0]
36
  matches.sort(key=lambda sr: sr[0], reverse=True)
37
  return [r for _score, r in matches]
38
 
 
 
 
 
 
 
 
 
 
 
 
39
  @staticmethod
40
  def _haystack(run: dict) -> str:
41
  return (run["transcript"] + " " + " ".join(run["line_items"])).lower()
@@ -44,4 +77,9 @@ class Memory:
44
  """Learned per-tech defaults derived from recorded runs."""
45
  counts = Counter(item for r in self._runs for item in r["line_items"])
46
  common = [item for item, _ in counts.most_common()]
47
- return {"common_items": common, "job_count": len(self._runs)}
 
 
 
 
 
 
10
 
11
 
12
  class Memory:
13
+ def __init__(self, path: str, embedder=None):
14
+ # `embedder` (anything with .encode(text)->vector) turns Recall semantic:
15
+ # run vectors are cached at record time, only the query is embedded at recall
16
+ # time (ADR-0003). Without one, Recall stays keyword-only (unchanged default).
17
  self._path = path
18
+ self._embedder = embedder
19
  self._runs: list[dict] = []
20
  self._load()
21
 
 
24
  with open(self._path) as f:
25
  self._runs = json.load(f).get("runs", [])
26
 
27
+ def record_run(
28
+ self, transcript: str, line_items: list[str], total: float | None = None
29
+ ) -> None:
30
+ run = {"transcript": transcript, "line_items": list(line_items), "total": total}
31
+ if self._embedder is not None:
32
+ # Cache the run's embedding now so recall only embeds the query.
33
+ run["embedding"] = list(self._embedder.encode(self._haystack(run)))
34
+ self._runs.append(run)
35
  self._save()
36
 
37
+ def recent(self, limit: int | None = None) -> list[dict]:
38
+ """Past runs newest-first, each tagged with a 1-based sequence id.
39
+
40
+ The id is the record order (not a wall-clock time) so it is deterministic
41
+ and offline-friendly. `total` is None for runs recorded before totals existed.
42
+ """
43
+ tagged = [{"id": i + 1, "total": r.get("total"), **r} for i, r in enumerate(self._runs)]
44
+ tagged.reverse() # newest first
45
+ return tagged[:limit] if limit is not None else tagged
46
+
47
  def _save(self) -> None:
48
  os.makedirs(os.path.dirname(self._path) or ".", exist_ok=True)
49
  with open(self._path, "w") as f:
50
  json.dump({"runs": self._runs}, f, indent=2)
51
 
52
  def recall(self, query: str) -> list[dict]:
53
+ if self._embedder is not None:
54
+ return self._semantic_recall(query)
55
  q = query.strip().lower()
56
  scored = [(self._haystack(r).count(q), r) for r in self._runs]
57
  matches = [(score, r) for score, r in scored if score > 0]
58
  matches.sort(key=lambda sr: sr[0], reverse=True)
59
  return [r for _score, r in matches]
60
 
61
+ def _semantic_recall(self, query: str) -> list[dict]:
62
+ """Rank past runs by embedding cosine similarity to the query (ADR-0003).
63
+
64
+ Reuses recall_eval's ranker so there is one cosine implementation. Runs
65
+ without a cached embedding (recorded before the embedder) fall to the bottom.
66
+ """
67
+ from quillwright.recall_eval import semantic_ranker
68
+
69
+ scored = [r for r in self._runs if r.get("embedding")]
70
+ return semantic_ranker(query, scored, self._embedder)
71
+
72
  @staticmethod
73
  def _haystack(run: dict) -> str:
74
  return (run["transcript"] + " " + " ".join(run["line_items"])).lower()
 
77
  """Learned per-tech defaults derived from recorded runs."""
78
  counts = Counter(item for r in self._runs for item in r["line_items"])
79
  common = [item for item, _ in counts.most_common()]
80
+ revenue_total = round(sum(r["total"] for r in self._runs if r.get("total")), 2)
81
+ return {
82
+ "common_items": common,
83
+ "job_count": len(self._runs),
84
+ "revenue_total": revenue_total,
85
+ }
quillwright/models.py CHANGED
@@ -19,7 +19,11 @@ class LineItem(BaseModel):
19
  quantity: float
20
  unit: str
21
  rate: float
22
- price_source: Literal["catalog", "user", "computed"] = "catalog"
 
 
 
 
23
 
24
  @computed_field
25
  @property
@@ -27,6 +31,23 @@ class LineItem(BaseModel):
27
  return round(self.quantity * self.rate, 2)
28
 
29
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  class Estimate(BaseModel):
31
  job_title: str
32
  line_items: list[LineItem] = []
 
19
  quantity: float
20
  unit: str
21
  rate: float
22
+ # "document" = a price Parse read from a Document Capture that the human has
23
+ # confirmed verbatim through the Agent Pause (ADR-0011). The document is the
24
+ # source, but the number is still user-gated — it never enters an Estimate
25
+ # straight from the model.
26
+ price_source: Literal["catalog", "user", "computed", "document"] = "catalog"
27
 
28
  @computed_field
29
  @property
 
31
  return round(self.quantity * self.rate, 2)
32
 
33
 
34
+ class ProposedLineItem(BaseModel):
35
+ """A priced row Parse read from a Document Capture, awaiting human confirmation.
36
+
37
+ Not an Estimate line yet: per Facts-from-Tools (ADR-0004) + ADR-0011, a price
38
+ a model read off a document is *proposed*, not a fact. The human confirms or
39
+ edits it via the Agent Pause; on confirm it becomes a LineItem with
40
+ price_source="document". `source_text` is the raw cell the price came from, so
41
+ the human can spot an OCR slip (e.g. "$42.50" misread as "$425.0").
42
+ """
43
+
44
+ description: str
45
+ quantity: float = 1.0
46
+ unit: str = "ea"
47
+ rate: float
48
+ source_text: str = ""
49
+
50
+
51
  class Estimate(BaseModel):
52
  job_title: str
53
  line_items: list[LineItem] = []
quillwright/pairing.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phone-capture pairing channel (Tier 3).
2
+
3
+ The desktop creates a pairing → gets a short ``code`` → renders a QR of
4
+ ``<public_base>/m/<code>``. The phone opens that mobile capture page, sends a capture
5
+ (photo server-path(s) +/or a transcript) to the pairing, and the desktop — which polls
6
+ ``/api/pair/<code>`` — picks it up and forges live on screen.
7
+
8
+ In-process and demo-scoped: a dict of ``code -> pending capture``. The capture is
9
+ delivered exactly once (the desktop forges it a single time), then cleared. A durable /
10
+ multi-session transport is a post-hackathon swap behind this same tiny interface — the
11
+ same shape as ``pdf_links`` and the ``EstimateStore``.
12
+
13
+ Codes come from ``os.urandom`` (URL-safe, unguessable enough for a demo; not
14
+ ``random``/wall-clock, so nothing here depends on the global RNG or the clock).
15
+ """
16
+
17
+ import base64
18
+ import os
19
+
20
+ # code -> {"capture": <dict|None>}. Process-local; lives for the server's lifetime.
21
+ _PAIRINGS: dict[str, dict] = {}
22
+
23
+
24
+ def _new_code() -> str:
25
+ """A short, URL-safe pairing code (6 chars, ~36 bits)."""
26
+ return base64.urlsafe_b64encode(os.urandom(5)).decode().rstrip("=")[:6]
27
+
28
+
29
+ def create() -> str:
30
+ """Open a new pairing and return its code (desktop side)."""
31
+ code = _new_code()
32
+ while code in _PAIRINGS: # vanishingly unlikely; keep codes unique anyway
33
+ code = _new_code()
34
+ _PAIRINGS[code] = {"capture": None}
35
+ return code
36
+
37
+
38
+ def is_valid(code: str) -> bool:
39
+ """Whether ``code`` is a live pairing (the mobile page checks before capturing)."""
40
+ return code in _PAIRINGS
41
+
42
+
43
+ def submit(code: str, capture: dict) -> bool:
44
+ """Phone side: hand a capture to the paired desktop. False if the code is unknown."""
45
+ if code not in _PAIRINGS:
46
+ return False
47
+ _PAIRINGS[code]["capture"] = capture
48
+ return True
49
+
50
+
51
+ def poll(code: str) -> dict | None:
52
+ """Desktop side: take the pending capture (delivered once), or None if none waiting."""
53
+ pairing = _PAIRINGS.get(code)
54
+ if not pairing or pairing["capture"] is None:
55
+ return None
56
+ capture = pairing["capture"]
57
+ pairing["capture"] = None # consume: the desktop forges it exactly once
58
+ return capture
59
+
60
+
61
+ def reset() -> None:
62
+ """Drop all pairings (tests)."""
63
+ _PAIRINGS.clear()
quillwright/recall_eval.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Score Recall: given a query, does the ranker put the right past run first?
2
+
3
+ The viability question for semantic Recall (ADR-0003) is "does meaning-based
4
+ re-ranking beat keyword matching on queries where the words differ but the intent
5
+ matches" (e.g. query "coolant" should find a run that says "refrigerant"). This
6
+ module measures recall@1 for any ranker, plus a keyword baseline, so we can report
7
+ a measured keyword-vs-semantic delta for the Field Notes write-up.
8
+
9
+ A `ranker` is `fn(query, runs) -> runs_ranked_best_first`. The keyword baseline
10
+ ranks by literal token overlap; the semantic ranker (embedding cosine) drops in
11
+ with the same signature once the embedder lands — no change here.
12
+ """
13
+
14
+ import json
15
+
16
+
17
+ def load_recall_cases(path: str) -> dict:
18
+ with open(path) as f:
19
+ return json.load(f)
20
+
21
+
22
+ def _haystack(run: dict) -> str:
23
+ return (run["transcript"] + " " + " ".join(run["line_items"])).lower()
24
+
25
+
26
+ def keyword_overlap(query: str, run: dict) -> int:
27
+ """How many query tokens appear literally in the run (the keyword signal)."""
28
+ hay = _haystack(run)
29
+ return sum(1 for tok in query.lower().split() if tok in hay)
30
+
31
+
32
+ def keyword_ranker(query: str, runs: list[dict]) -> list[dict]:
33
+ """Baseline: rank by literal token overlap, ties keep corpus order (stable)."""
34
+ return sorted(runs, key=lambda r: keyword_overlap(query, r), reverse=True)
35
+
36
+
37
+ def recall_at_1(queries: list[dict], corpus: list[dict], ranker) -> float:
38
+ """Fraction of queries whose gold run is ranked first by `ranker`.
39
+
40
+ Each query is {"query": str, "gold_id": int}; runs are matched by "id".
41
+ """
42
+ if not queries:
43
+ return 0.0
44
+ hits = 0
45
+ for q in queries:
46
+ ranked = ranker(q["query"], corpus)
47
+ if ranked and ranked[0]["id"] == q["gold_id"]:
48
+ hits += 1
49
+ return round(hits / len(queries), 3)
50
+
51
+
52
+ def embed_corpus(corpus: list[dict], embedder) -> list[dict]:
53
+ """Attach a cached embedding to each run (mirrors record-time caching in prod).
54
+
55
+ `embedder` is anything with `.encode(text) -> vector`; we embed the same haystack
56
+ (transcript + line items) the keyword path searches.
57
+ """
58
+ out = []
59
+ for r in corpus:
60
+ out.append({**r, "embedding": list(embedder.encode(_haystack(r)))})
61
+ return out
62
+
63
+
64
+ def _cosine(a, b) -> float:
65
+ import numpy as np
66
+
67
+ va, vb = np.asarray(a, dtype=float), np.asarray(b, dtype=float)
68
+ na, nb = np.linalg.norm(va), np.linalg.norm(vb)
69
+ if na == 0 or nb == 0:
70
+ return 0.0
71
+ return float(va @ vb / (na * nb))
72
+
73
+
74
+ def semantic_ranker(query: str, runs: list[dict], embedder) -> list[dict]:
75
+ """Rank runs by cosine similarity between the query embedding and each run's
76
+ cached embedding. Only the query is embedded at call time (torch out of the hot
77
+ path); run embeddings come from embed_corpus / record-time caching (ADR-0003)."""
78
+ qv = embedder.encode(query)
79
+ return sorted(runs, key=lambda r: _cosine(qv, r.get("embedding", [])), reverse=True)
80
+
81
+
82
+ def keyword_recall_at_1(queries: list[dict], corpus: list[dict]) -> float:
83
+ """recall@1 using the keyword baseline ranker.
84
+
85
+ Note: a query with zero literal overlap leaves the corpus in its original
86
+ order, so the first run is a non-match — that miss is the point (it's where
87
+ semantic recall should win).
88
+ """
89
+
90
+ def ranker(query, runs):
91
+ ranked = keyword_ranker(query, runs)
92
+ # if nothing overlaps at all, treat it as no result (a guaranteed miss),
93
+ # rather than crediting whatever happened to sort first.
94
+ if ranked and keyword_overlap(query, ranked[0]) == 0:
95
+ return []
96
+ return ranked
97
+
98
+ return recall_at_1(queries, corpus, ranker)
quillwright/resolver.py CHANGED
@@ -32,16 +32,18 @@ class StubModel:
32
 
33
 
34
  # Which concrete model fills each role per Mode. Real backends wired later (ADR-0005).
35
- # Display labels per role (used by the stub backend).
 
 
36
  PRIVATE_STACK = {
37
- "perception": "MiniCPM-V-4.6",
38
- "audio": "whisper-local",
39
- "brain": "gpt-oss-20b",
40
  }
41
  BEST_STACK = {
42
  "perception": "Nemotron-3-Nano-Omni",
43
  "audio": "Nemotron-3-Nano-Omni",
44
- "brain": "gpt-oss-20b",
45
  }
46
 
47
  # Actual locally-available Ollama tags per role (what we really run on-device).
@@ -51,6 +53,114 @@ OLLAMA_TAGS = {
51
  "multilingual": "aya",
52
  }
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
  class ModelResolver:
56
  def __init__(
@@ -67,12 +177,38 @@ class ModelResolver:
67
  def for_role(self, role: str) -> Model:
68
  if role in self._overrides:
69
  return self._overrides[role]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  if self._backend == "ollama":
71
  if role not in OLLAMA_TAGS:
72
  raise KeyError(f"no ollama tag for role: {role}")
73
  from quillwright.backends.ollama import OllamaModel
74
 
75
  return OllamaModel(OLLAMA_TAGS[role])
 
 
 
 
 
 
76
  if role not in self._roles:
77
  raise KeyError(f"unknown role: {role}")
78
  return StubModel(responses=[""], name=self._roles[role])
 
32
 
33
 
34
  # Which concrete model fills each role per Mode. Real backends wired later (ADR-0005).
35
+ # Display labels per role (used by the stub backend). These are LABELS ONLY — the
36
+ # real on-device models are in OLLAMA_TAGS below (the brain is Nemotron, not gpt-oss;
37
+ # ADR-0009 superseded the gpt-oss mapping).
38
  PRIVATE_STACK = {
39
+ "perception": "MiniCPM-V",
40
+ "audio": "Cohere-Transcribe",
41
+ "brain": "Nemotron-3-Nano-4B",
42
  }
43
  BEST_STACK = {
44
  "perception": "Nemotron-3-Nano-Omni",
45
  "audio": "Nemotron-3-Nano-Omni",
46
+ "brain": "Nemotron-3-Nano-30B",
47
  }
48
 
49
  # Actual locally-available Ollama tags per role (what we really run on-device).
 
53
  "multilingual": "aya",
54
  }
55
 
56
+ # Roles served on Modal (ADR-0005 hosted compute, ADR-0009 Best Stack). Labels are
57
+ # informational; each role's modal app pins the real repo id (see backends/modal.py
58
+ # ROLE_ENDPOINTS). Perception + audio share the ONE Omni deployment (omnimodal).
59
+ MODAL_ROLES = {
60
+ "brain": "nemotron-3-nano-30b-a3b",
61
+ "perception": "nemotron-3-nano-omni-30b-a3b",
62
+ "audio": "nemotron-3-nano-omni-30b-a3b",
63
+ "multilingual": "aya-expanse-8b",
64
+ }
65
+
66
+
67
+ def brain_resolver() -> "ModelResolver":
68
+ """The resolver for the agent brain, chosen by env (one source of truth).
69
+
70
+ FF_BACKEND=modal -> Best-Stack brain (Nemotron 30B) hosted on Modal (ADR-0009).
71
+ otherwise -> Private-Stack brain (Nemotron 4B) via local Ollama.
72
+
73
+ The brain is special: FF_BACKEND=modal *means* "brain on Modal", so a missing
74
+ FF_MODAL_BRAIN_URL fails LOUD in ModalModel rather than silently downgrading.
75
+ Other roles opt in per-URL via modal_resolver_if_configured().
76
+ """
77
+ import os
78
+
79
+ if os.environ.get("FF_BACKEND") == "modal":
80
+ return ModelResolver(mode="best", backend="modal")
81
+ return ModelResolver(mode="private", backend="ollama")
82
+
83
+
84
+ def modal_resolver_if_configured(role: str) -> "ModelResolver | None":
85
+ """A Best-Stack Modal resolver for `role`, or None when the local path should run.
86
+
87
+ Per-role opt-in (mirrors FF_MODAL_PARSE_URL): FF_BACKEND=modal moves ONLY the
88
+ brain; perception/audio/multilingual each ride Modal IFF their own URL env is
89
+ also set. Callers fall back to their existing local/stub path on None — turning
90
+ on the hosted brain never breaks a role whose GPU app isn't deployed.
91
+ """
92
+ import os
93
+
94
+ if os.environ.get("FF_BACKEND") != "modal":
95
+ return None
96
+ from quillwright.backends.modal import ROLE_ENDPOINTS
97
+
98
+ if role not in ROLE_ENDPOINTS or not os.environ.get(ROLE_ENDPOINTS[role][0]):
99
+ return None
100
+ return ModelResolver(mode="best", backend="modal")
101
+
102
+
103
+ # Human-facing labels for the Modal Best-Stack models (the resolver tags above are
104
+ # terse; these read well in the UI badge).
105
+ MODAL_LABELS = {
106
+ "brain": "Nemotron-3-Nano-30B",
107
+ "perception": "Nemotron-Omni-30B",
108
+ "audio": "Nemotron-Omni-30B",
109
+ "multilingual": "Aya-Expanse-8B",
110
+ }
111
+
112
+ # Roles shown in the UI badge (audio is omitted — it has no always-on indicator and
113
+ # rides the same deployment as perception).
114
+ _BADGE_ROLES = ("brain", "perception", "multilingual")
115
+
116
+
117
+ def active_models() -> dict:
118
+ """Where each Model Role actually resolves right now, for the UI badge + banner.
119
+
120
+ Reads the same env the resolvers do (FF_REAL_MODELS / FF_BACKEND / FF_MODAL_*_URL)
121
+ so it is one honest source of truth — not a guess. Returns
122
+ {"mode": <stub|local|modal|mixed>, "roles": {role: <label>}}.
123
+
124
+ `mode` summarizes the spread: "stub" if nothing is real, "local" if every real
125
+ role is on Ollama, "modal" if every real role is on Modal, "mixed" otherwise
126
+ (e.g. FF_BACKEND=modal moves only the brain — the rest stay local).
127
+ """
128
+ import os
129
+
130
+ real = os.environ.get("FF_REAL_MODELS") == "1"
131
+ modal_brain = os.environ.get("FF_BACKEND") == "modal"
132
+
133
+ if not real and not modal_brain:
134
+ return {"mode": "stub", "roles": {r: "stub" for r in _BADGE_ROLES}}
135
+
136
+ def _where(role: str) -> str:
137
+ # A role is on Modal iff its own URL is configured (brain keys off the
138
+ # backend flag; the others opt in per-URL — mirrors the resolvers).
139
+ if modal_resolver_if_configured(role) is not None:
140
+ return "modal"
141
+ return "local" if real else "stub"
142
+
143
+ roles, backends = {}, set()
144
+ for role in _BADGE_ROLES:
145
+ where = _where(role)
146
+ backends.add(where)
147
+ if where == "modal":
148
+ roles[role] = MODAL_LABELS[role]
149
+ elif where == "local":
150
+ roles[role] = OLLAMA_TAGS[role]
151
+ else:
152
+ roles[role] = "stub"
153
+
154
+ # A single uniform backend across all badge roles names the mode; any spread
155
+ # (e.g. brain on Modal but the rest stubbed/local) is honestly "mixed".
156
+ if backends == {"modal"}:
157
+ mode = "modal"
158
+ elif backends == {"local"}:
159
+ mode = "local"
160
+ else:
161
+ mode = "mixed"
162
+ return {"mode": mode, "roles": roles}
163
+
164
 
165
  class ModelResolver:
166
  def __init__(
 
177
  def for_role(self, role: str) -> Model:
178
  if role in self._overrides:
179
  return self._overrides[role]
180
+ # Embedding has ONE serving path (sentence-transformers, ADR-0003) regardless
181
+ # of mode/backend — it is not an Ollama/Modal model. Resolve it directly.
182
+ if role == "embedding" and self._backend != "stub":
183
+ from quillwright.backends.embedding import EmbeddingModel
184
+
185
+ return EmbeddingModel()
186
+ # Audio: on-device Cohere Transcribe via transformers (ADR-0009) — not Ollama —
187
+ # EXCEPT under the modal backend, where it rides the hosted Omni deployment
188
+ # like any other MODAL_ROLES entry (falls through to the modal branch below).
189
+ if role == "audio" and self._backend not in ("stub", "modal"):
190
+ from quillwright.backends.audio import AudioModel
191
+
192
+ return AudioModel()
193
+ # Extraction (Nemotron Parse) has ONE serving path too, but a REMOTE one:
194
+ # it is visual, so it never fits Ollama/vLLM and is always hosted on Modal
195
+ # (ADR-0011). The Modal endpoint URL comes from FF_MODAL_PARSE_URL.
196
+ if role == "extraction" and self._backend != "stub":
197
+ from quillwright.backends.parse import ParseModel
198
+
199
+ return ParseModel()
200
  if self._backend == "ollama":
201
  if role not in OLLAMA_TAGS:
202
  raise KeyError(f"no ollama tag for role: {role}")
203
  from quillwright.backends.ollama import OllamaModel
204
 
205
  return OllamaModel(OLLAMA_TAGS[role])
206
+ if self._backend == "modal":
207
+ if role not in MODAL_ROLES:
208
+ raise KeyError(f"role '{role}' is not served on Modal")
209
+ from quillwright.backends.modal import ModalModel
210
+
211
+ return ModalModel(MODAL_ROLES[role], role=role)
212
  if role not in self._roles:
213
  raise KeyError(f"unknown role: {role}")
214
  return StubModel(responses=[""], name=self._roles[role])
quillwright/server.py CHANGED
@@ -5,25 +5,34 @@ all business logic lives in quillwright.agent and is adapted in quillwright.api.
5
  """
6
 
7
  import json
 
8
  from pathlib import Path
9
 
10
- from fastapi import Body
11
- from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
12
  from gradio import Server
13
 
14
  from quillwright.api.estimate import (
 
15
  forge_estimate,
16
  forge_estimate_stream,
17
  resume_estimate_stream,
 
18
  )
19
- import os
20
-
 
 
 
21
  from quillwright.api.recalc import recalc_estimate
 
 
 
22
  from quillwright.api.translate import translate_estimate
23
  from quillwright.api.upload import save_upload
24
  from quillwright.models import Estimate, LineItem
25
  from quillwright.pdf import estimate_to_pdf
26
- from quillwright.resolver import ModelResolver
27
 
28
  REAL_MODELS = os.environ.get("FF_REAL_MODELS") == "1"
29
 
@@ -32,11 +41,52 @@ WEB = Path(__file__).parent / "web"
32
  app = Server()
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  @app.get("/", response_class=HTMLResponse)
36
  def index() -> str:
37
  return (WEB / "index.html").read_text()
38
 
39
 
 
 
 
 
 
 
 
40
  @app.post("/api/forge_estimate")
41
  def api_forge_estimate(payload: dict = Body(...)) -> dict:
42
  return forge_estimate(payload.get("transcript", ""), payload.get("trade", "hvac"))
@@ -54,6 +104,22 @@ def api_upload(payload: dict = Body(...)) -> dict:
54
  return {"path": path}
55
 
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  @app.post("/api/recalc")
58
  def api_recalc(payload: dict = Body(...)) -> dict:
59
  """Recompute totals from edited rows (server-authoritative math)."""
@@ -73,9 +139,15 @@ def api_translate(payload: dict = Body(...)) -> dict:
73
  tax_rate=payload.get("tax_rate", 0.13),
74
  )
75
  language = payload.get("language", "English")
76
- if REAL_MODELS and not language.lower().startswith("english"):
77
- model = ModelResolver(mode="private", backend="ollama").for_role("multilingual")
78
- est = translate_estimate(est, language, model)
 
 
 
 
 
 
79
  return est
80
 
81
 
@@ -101,6 +173,320 @@ def api_pdf(payload: dict = Body(...)) -> FileResponse:
101
  return FileResponse(path, media_type="application/pdf", filename="estimate.pdf")
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  @app.post("/api/forge_estimate_stream")
105
  def api_forge_estimate_stream(payload: dict = Body(...)) -> StreamingResponse:
106
  transcript = payload.get("transcript", "")
@@ -123,11 +509,54 @@ def api_resume_estimate_stream(payload: dict = Body(...)) -> StreamingResponse:
123
  )
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  @app.get("/web/{path:path}")
127
  def static_files(path: str):
128
  target = (WEB / path).resolve()
129
  if WEB.resolve() in target.parents and target.is_file():
130
- return FileResponse(target)
 
 
131
  return HTMLResponse("not found", status_code=404)
132
 
133
 
 
5
  """
6
 
7
  import json
8
+ import os
9
  from pathlib import Path
10
 
11
+ from fastapi import Body, Request
12
+ from fastapi.responses import FileResponse, HTMLResponse, Response, StreamingResponse
13
  from gradio import Server
14
 
15
  from quillwright.api.estimate import (
16
+ estimate_store,
17
  forge_estimate,
18
  forge_estimate_stream,
19
  resume_estimate_stream,
20
+ save_estimate_record,
21
  )
22
+ from quillwright.api.chat import chat_about_estimate
23
+ from quillwright.api.document import parse_document_capture
24
+ from quillwright.api.export import estimate_to_json_payload
25
+ from quillwright.api.pages import dashboard_data, inventory_data, jobs_data
26
+ from quillwright.api.pdf_links import get_pdf, public_pdf_url, register_pdf
27
  from quillwright.api.recalc import recalc_estimate
28
+ from quillwright.api.send import SendError, resolve_send_mode, send_estimate
29
+ from quillwright.api.send import _render_pdf_bytes as render_estimate_pdf_bytes
30
+ from quillwright.api.transcribe import transcribe_audio
31
  from quillwright.api.translate import translate_estimate
32
  from quillwright.api.upload import save_upload
33
  from quillwright.models import Estimate, LineItem
34
  from quillwright.pdf import estimate_to_pdf
35
+ from quillwright.resolver import ModelResolver, active_models
36
 
37
  REAL_MODELS = os.environ.get("FF_REAL_MODELS") == "1"
38
 
 
41
  app = Server()
42
 
43
 
44
+ def _announce_mode() -> None:
45
+ """Print which model mode the server booted in — so 'is a model being hit?'
46
+ is answerable at a glance instead of a silent guess."""
47
+ from quillwright.resolver import OLLAMA_TAGS
48
+
49
+ line = "=" * 60
50
+ if not REAL_MODELS:
51
+ print(f"\n{line}\n[quillwright] STUB MODE — no models hit (deterministic / keyword).")
52
+ print(" Set FF_REAL_MODELS=1 to run the real local models via Ollama.")
53
+ print(f"{line}\n", flush=True)
54
+ return
55
+
56
+ # Real mode: name the models and check Ollama is actually reachable.
57
+ import requests
58
+
59
+ tags = ", ".join(f"{role}={tag}" for role, tag in OLLAMA_TAGS.items())
60
+ print(f"\n{line}\n[quillwright] REAL MODELS via Ollama — {tags}")
61
+ try:
62
+ r = requests.get("http://localhost:11434/api/tags", timeout=2)
63
+ have = {m["name"].split(":")[0] for m in r.json().get("models", [])}
64
+ missing = [t for t in OLLAMA_TAGS.values() if t.split(":")[0] not in have]
65
+ if missing:
66
+ print(f" ⚠️ Ollama is up but these tags are NOT pulled: {missing}")
67
+ else:
68
+ print(" ✓ Ollama reachable; all role models are pulled.")
69
+ except Exception as exc: # noqa: BLE001 — startup banner, surface any failure
70
+ print(f" ⚠️ FF_REAL_MODELS=1 but Ollama is NOT reachable ({exc}).")
71
+ print(" The brain will ERROR (not silently stub) on the first real call.")
72
+ print(f"{line}\n", flush=True)
73
+
74
+
75
+ _announce_mode()
76
+
77
+
78
  @app.get("/", response_class=HTMLResponse)
79
  def index() -> str:
80
  return (WEB / "index.html").read_text()
81
 
82
 
83
+ @app.get("/api/model_info")
84
+ def api_model_info() -> dict:
85
+ """Which model fills each role right now (mode + per-role labels) for the UI
86
+ badge — one honest source of truth, read from the same env the resolvers use."""
87
+ return active_models()
88
+
89
+
90
  @app.post("/api/forge_estimate")
91
  def api_forge_estimate(payload: dict = Body(...)) -> dict:
92
  return forge_estimate(payload.get("transcript", ""), payload.get("trade", "hvac"))
 
104
  return {"path": path}
105
 
106
 
107
+ @app.post("/api/parse_document")
108
+ def api_parse_document(payload: dict = Body(...)) -> dict:
109
+ """Document Capture (ADR-0011): read a handed-over document (supplier quote,
110
+ spec sheet) into Proposed Line Items the human confirms before they enter
111
+ the estimate."""
112
+ path = save_upload(payload["data"], payload.get("filename", "document.png"))
113
+ return parse_document_capture(path)
114
+
115
+
116
+ @app.post("/api/transcribe")
117
+ def api_transcribe(payload: dict = Body(...)) -> dict:
118
+ """Transcribe a base64 voice note into text (Cohere Transcribe, on-device)."""
119
+ path = save_upload(payload["data"], payload.get("filename", "note.wav"))
120
+ return transcribe_audio(path)
121
+
122
+
123
  @app.post("/api/recalc")
124
  def api_recalc(payload: dict = Body(...)) -> dict:
125
  """Recompute totals from edited rows (server-authoritative math)."""
 
139
  tax_rate=payload.get("tax_rate", 0.13),
140
  )
141
  language = payload.get("language", "English")
142
+ if not language.lower().startswith("english"):
143
+ from quillwright.resolver import modal_resolver_if_configured
144
+
145
+ modal = modal_resolver_if_configured("multilingual")
146
+ if modal is not None: # Best-Stack Aya on Modal
147
+ est = translate_estimate(est, language, modal.for_role("multilingual"))
148
+ elif REAL_MODELS: # Private-Stack Aya via local Ollama
149
+ model = ModelResolver(mode="private", backend="ollama").for_role("multilingual")
150
+ est = translate_estimate(est, language, model)
151
  return est
152
 
153
 
 
173
  return FileResponse(path, media_type="application/pdf", filename="estimate.pdf")
174
 
175
 
176
+ @app.post("/api/send_estimate")
177
+ def api_send_estimate(request: Request, payload: dict = Body(...)):
178
+ """Finalize & Send (S10): deliver the estimate by SMS (Twilio MMS) or email
179
+ (SendGrid, PDF attached). Real send runs on the local path (FF_SEND_ENABLED=1 +
180
+ creds); the public Space drafts only (honest framing, ADR-0005).
181
+
182
+ For the SMS path in real mode we mint a public PDF URL the carrier can fetch
183
+ (MMS attaches by URL, not file)."""
184
+ channel = payload.get("channel", "")
185
+ recipient = payload.get("recipient", "")
186
+ rows = payload.get("rows", [])
187
+ job_title = payload.get("job_title", "Estimate")
188
+ tax_rate = payload.get("tax_rate", 0.13)
189
+
190
+ try:
191
+ pdf_url = None
192
+ if resolve_send_mode() == "real" and channel == "sms":
193
+ # Render the PDF, register it, and hand Twilio a URL it can GET. Inside
194
+ # the try so a render/IO failure returns 400 like the email path, not 500.
195
+ pdf_bytes = render_estimate_pdf_bytes(rows, job_title=job_title, tax_rate=tax_rate)
196
+ token = register_pdf(pdf_bytes)
197
+ pdf_url = public_pdf_url(token, base_url=str(request.base_url))
198
+
199
+ return send_estimate(
200
+ channel=channel,
201
+ recipient=recipient,
202
+ rows=rows,
203
+ job_title=job_title,
204
+ tax_rate=tax_rate,
205
+ pdf_url=pdf_url,
206
+ )
207
+ except SendError as exc:
208
+ return Response(content=str(exc), status_code=400, media_type="text/plain")
209
+ except Exception as exc: # noqa: BLE001 — PDF/IO failure → loud 400, never a bare 500
210
+ return Response(content=f"sms send failed: {exc}", status_code=400, media_type="text/plain")
211
+
212
+
213
+ @app.post("/api/voice/incoming")
214
+ async def api_voice_incoming(request: Request):
215
+ """Twilio Voice inbound webhook (S12): answer the call with greeting + <Record>.
216
+ Returns TwiML; the recording posts to /api/voice/recording on hang-up."""
217
+ from quillwright.api.voice import greeting_twiml
218
+
219
+ base = os.environ.get("FF_PUBLIC_BASE_URL") or str(request.base_url).rstrip("/")
220
+ return Response(content=greeting_twiml(base_url=base), media_type="application/xml")
221
+
222
+
223
+ @app.post("/api/voice/recording")
224
+ async def api_voice_recording(request: Request):
225
+ """Twilio recording-complete webhook (S12): kick the forge off on a background thread
226
+ and return a holding response immediately — the work (model load + brain) far exceeds
227
+ Twilio's ~15s webhook timeout, so we poll via /api/voice/status. Reads RecordingUrl +
228
+ From + CallSid from Twilio's form post."""
229
+ from quillwright.api.voice import start_recording_job
230
+
231
+ form = await request.form()
232
+ recording_url = str(form.get("RecordingUrl", ""))
233
+ from_number = str(form.get("From", ""))
234
+ call_sid = str(form.get("CallSid", "default"))
235
+ base = os.environ.get("FF_PUBLIC_BASE_URL") or str(request.base_url).rstrip("/")
236
+ try:
237
+ twiml = start_recording_job(
238
+ recording_url=recording_url,
239
+ from_number=from_number,
240
+ call_sid=call_sid,
241
+ base_url=base,
242
+ )
243
+ except Exception as exc: # noqa: BLE001 — always answer Twilio with valid TwiML
244
+ from quillwright.api.voice import _say_response
245
+
246
+ print(f"[quillwright] voice recording handler failed: {exc}", flush=True)
247
+ twiml = _say_response(
248
+ "Sorry, something went wrong forging your estimate. Please try again."
249
+ )
250
+ return Response(content=twiml, media_type="application/xml")
251
+
252
+
253
+ @app.post("/api/voice/status")
254
+ async def api_voice_status(request: Request):
255
+ """Twilio poll target (S12): the background forge isn't done → hold + redirect again;
256
+ done → the spoken total + refine <Gather> (or an error fallback)."""
257
+ from quillwright.api.voice import handle_status
258
+
259
+ form = await request.form()
260
+ call_sid = str(form.get("CallSid", "default"))
261
+ base = os.environ.get("FF_PUBLIC_BASE_URL") or str(request.base_url).rstrip("/")
262
+ try:
263
+ twiml = handle_status(call_sid=call_sid, base_url=base)
264
+ except Exception as exc: # noqa: BLE001 — always answer Twilio with valid TwiML
265
+ from quillwright.api.voice import _say_response
266
+
267
+ print(f"[quillwright] voice status handler failed: {exc}", flush=True)
268
+ twiml = _say_response("Sorry, something went wrong. It's saved as a draft. Goodbye.")
269
+ return Response(content=twiml, media_type="application/xml")
270
+
271
+
272
+ @app.post("/api/voice/refine")
273
+ async def api_voice_refine(request: Request):
274
+ """Twilio <Gather> webhook (S12, Tier A): one caller turn in the refine loop. Reads
275
+ the spoken SpeechResult + CallSid; applies the edit (or finishes + texts the PDF)."""
276
+ from quillwright.api.voice import handle_refine
277
+
278
+ form = await request.form()
279
+ call_sid = str(form.get("CallSid", "default"))
280
+ speech_result = str(form.get("SpeechResult", ""))
281
+ base = os.environ.get("FF_PUBLIC_BASE_URL") or str(request.base_url).rstrip("/")
282
+ try:
283
+ twiml = handle_refine(call_sid=call_sid, speech_result=speech_result, base_url=base)[
284
+ "twiml"
285
+ ]
286
+ except Exception as exc: # noqa: BLE001 — always answer Twilio with valid TwiML
287
+ from quillwright.api.voice import _say_response
288
+
289
+ print(f"[quillwright] voice refine handler failed: {exc}", flush=True)
290
+ twiml = _say_response("Sorry, something went wrong. It's saved as a draft. Goodbye.")
291
+ return Response(content=twiml, media_type="application/xml")
292
+
293
+
294
+ # --- Voice-agent tools (ElevenLabs Conversational AI calls these; Quillwright stays the
295
+ # source of truth — every number is a tool response, never the agent's speech). ---
296
+
297
+
298
+ async def _tool_payload(request: Request) -> dict:
299
+ """Parse a tool-call body tolerantly. ElevenLabs (and other webhook callers) don't
300
+ always set Content-Type: application/json, which makes FastAPI's Body(...) reject the
301
+ request with a 422. So we read the raw body and JSON-parse it ourselves, falling back
302
+ to form fields — the tool works regardless of how the caller labels the body."""
303
+ raw = await request.body()
304
+ if raw:
305
+ try:
306
+ data = json.loads(raw)
307
+ except (json.JSONDecodeError, ValueError):
308
+ data = None
309
+ if isinstance(data, dict):
310
+ # ElevenLabs may wrap the args under a key (e.g. "parameters"/"body"/"arguments").
311
+ # If the dict has exactly one value that is itself a dict, unwrap it.
312
+ if not any(k in data for k in ("session_id", "description", "request", "item", "to")):
313
+ for v in data.values():
314
+ if isinstance(v, dict):
315
+ return v
316
+ return data
317
+ try:
318
+ form = await request.form()
319
+ if form:
320
+ return dict(form)
321
+ except Exception: # noqa: BLE001 — no parseable body; treat as empty
322
+ pass
323
+ return {}
324
+
325
+
326
+ @app.post("/api/tools/forge")
327
+ async def api_tool_forge(request: Request) -> dict:
328
+ """Forge an estimate from a spoken job description (keyed by the agent's session_id)."""
329
+ from quillwright.api.tools_api import forge
330
+
331
+ payload = await _tool_payload(request)
332
+ return forge(payload.get("session_id", "default"), payload.get("description", ""))
333
+
334
+
335
+ @app.post("/api/tools/edit")
336
+ async def api_tool_edit(request: Request) -> dict:
337
+ """Add / remove / change a line on the session's estimate (catalog-priced)."""
338
+ from quillwright.api.tools_api import edit
339
+
340
+ payload = await _tool_payload(request)
341
+ return edit(payload.get("session_id", "default"), payload.get("request", ""))
342
+
343
+
344
+ @app.post("/api/tools/lookup_price")
345
+ async def api_tool_lookup_price(request: Request) -> dict:
346
+ """A single catalog price (read-only)."""
347
+ from quillwright.api.tools_api import lookup_price
348
+
349
+ payload = await _tool_payload(request)
350
+ return lookup_price(payload.get("item", ""))
351
+
352
+
353
+ @app.post("/api/tools/text_estimate")
354
+ async def api_tool_text_estimate(request: Request) -> dict:
355
+ """SMS the session's estimate PDF to the caller."""
356
+ from quillwright.api.tools_api import text_estimate
357
+
358
+ payload = await _tool_payload(request)
359
+ base = os.environ.get("FF_PUBLIC_BASE_URL") or str(request.base_url).rstrip("/")
360
+ return text_estimate(
361
+ payload.get("session_id", "default"), to=payload.get("to", ""), base_url=base
362
+ )
363
+
364
+
365
+ @app.get("/api/estimate_pdf/{token}")
366
+ def api_estimate_pdf(token: str):
367
+ """Serve a previously-rendered estimate PDF by token, so Twilio MMS can fetch
368
+ it as the message attachment (S10)."""
369
+ pdf_bytes = get_pdf(token)
370
+ if pdf_bytes is None:
371
+ return HTMLResponse("not found", status_code=404)
372
+ return Response(content=pdf_bytes, media_type="application/pdf")
373
+
374
+
375
+ # --- QR phone-capture + desktop pairing (Tier 3). ---
376
+
377
+
378
+ @app.post("/api/pair/create")
379
+ def api_pair_create(request: Request) -> dict:
380
+ """Open a pairing for this desktop session: return the code, the mobile capture
381
+ URL (tunnel base + /m/<code>), and an inline SVG QR encoding that URL."""
382
+ from quillwright.api.qr import qr_svg
383
+ from quillwright.pairing import create
384
+
385
+ code = create()
386
+ base = os.environ.get("FF_PUBLIC_BASE_URL") or str(request.base_url).rstrip("/")
387
+ capture_url = f"{base.rstrip('/')}/m/{code}"
388
+ return {"code": code, "capture_url": capture_url, "qr_svg": qr_svg(capture_url)}
389
+
390
+
391
+ @app.get("/api/pair/{code}")
392
+ def api_pair_poll(code: str) -> dict:
393
+ """Desktop poll: the pending capture from the paired phone (once), or null."""
394
+ from quillwright.pairing import poll
395
+
396
+ return {"capture": poll(code)}
397
+
398
+
399
+ @app.post("/api/pair/{code}/capture")
400
+ def api_pair_capture(code: str, payload: dict = Body(...)):
401
+ """Phone side: hand a captured photo path(s) + transcript to the paired desktop."""
402
+ from quillwright.pairing import submit
403
+
404
+ ok = submit(
405
+ code,
406
+ {
407
+ "image_paths": payload.get("image_paths", []),
408
+ "transcript": payload.get("transcript", ""),
409
+ },
410
+ )
411
+ if not ok:
412
+ return HTMLResponse("unknown pairing", status_code=404)
413
+ return {"ok": True}
414
+
415
+
416
+ @app.get("/m/{code}", response_class=HTMLResponse)
417
+ def mobile_capture_page(code: str):
418
+ """The dedicated mobile capture page (purpose-built for phone — the one media query
419
+ in the app). 404 for an unknown/expired code so a stale QR fails honestly."""
420
+ from quillwright.pairing import is_valid
421
+
422
+ if not is_valid(code):
423
+ return HTMLResponse("This pairing has expired. Generate a new QR on the desktop.", 404)
424
+ return (WEB / "mobile.html").read_text()
425
+
426
+
427
+ @app.post("/api/export_json")
428
+ def api_export_json(payload: dict = Body(...)) -> dict:
429
+ """Machine-readable JSON of the (edited) estimate — the 'no lock-in' export."""
430
+ return estimate_to_json_payload(
431
+ payload.get("rows", []),
432
+ job_title=payload.get("job_title", "Estimate"),
433
+ tax_rate=payload.get("tax_rate", 0.13),
434
+ )
435
+
436
+
437
+ @app.post("/api/chat")
438
+ def api_chat(payload: dict = Body(...)) -> dict:
439
+ """Conversational refinement of the current estimate (Facts-from-Tools holds).
440
+
441
+ Carries the Refinement Thread (ADR-0013) in and back out so the conversation is
442
+ resumable: sanitized history (no dollars) is replayed for reference resolution."""
443
+ return chat_about_estimate(
444
+ payload.get("message", ""),
445
+ payload.get("rows", []),
446
+ tax_rate=payload.get("tax_rate", 0.13),
447
+ thread=payload.get("thread", []),
448
+ pending=payload.get("pending"),
449
+ )
450
+
451
+
452
+ # --- Saved Estimates (ADR-0013): per-account Estimate Store. ---
453
+
454
+
455
+ @app.post("/api/save_estimate")
456
+ def api_save_estimate(payload: dict = Body(...)) -> dict:
457
+ """Persist (create or update-in-place) a Saved Estimate + its Refinement Thread."""
458
+ rec = save_estimate_record(
459
+ payload.get("rows", []),
460
+ job_title=payload.get("job_title", "Estimate"),
461
+ tax_rate=payload.get("tax_rate", 0.13),
462
+ thread=payload.get("thread", []),
463
+ id=payload.get("id"),
464
+ )
465
+ return {"id": rec["id"]}
466
+
467
+
468
+ @app.get("/api/estimates")
469
+ def api_estimates() -> dict:
470
+ """The account's Saved Estimates, newest first (id + title + total summaries)."""
471
+ return {"estimates": estimate_store().list_estimates()}
472
+
473
+
474
+ @app.get("/api/estimate/{id}")
475
+ def api_estimate(id: str):
476
+ """Reopen one Saved Estimate (frozen snapshot + its Refinement Thread)."""
477
+ rec = estimate_store().load(id)
478
+ if rec is None:
479
+ return HTMLResponse("not found", status_code=404)
480
+ return rec
481
+
482
+
483
+ @app.delete("/api/estimate/{id}")
484
+ def api_delete_estimate(id: str) -> dict:
485
+ """Discard a Saved Estimate."""
486
+ estimate_store().delete(id)
487
+ return {"ok": True}
488
+
489
+
490
  @app.post("/api/forge_estimate_stream")
491
  def api_forge_estimate_stream(payload: dict = Body(...)) -> StreamingResponse:
492
  transcript = payload.get("transcript", "")
 
509
  )
510
 
511
 
512
+ # --- Secondary pages (ADR-0010): demoable-first read-models over real data. ---
513
+
514
+
515
+ @app.get("/dashboard", response_class=HTMLResponse)
516
+ def dashboard_page() -> str:
517
+ return (WEB / "dashboard.html").read_text()
518
+
519
+
520
+ @app.get("/estimates", response_class=HTMLResponse)
521
+ def estimates_page() -> str:
522
+ return (WEB / "estimates.html").read_text()
523
+
524
+
525
+ @app.get("/jobs", response_class=HTMLResponse)
526
+ def jobs_page() -> str:
527
+ return (WEB / "jobs.html").read_text()
528
+
529
+
530
+ @app.get("/inventory", response_class=HTMLResponse)
531
+ def inventory_page() -> str:
532
+ return (WEB / "inventory.html").read_text()
533
+
534
+
535
+ @app.get("/api/dashboard")
536
+ def api_dashboard() -> dict:
537
+ """KPIs + recent jobs aggregated over the real on-device memory store."""
538
+ return dashboard_data()
539
+
540
+
541
+ @app.get("/api/jobs")
542
+ def api_jobs() -> dict:
543
+ """Past Runs from the real memory store, newest first."""
544
+ return jobs_data()
545
+
546
+
547
+ @app.get("/api/inventory")
548
+ def api_inventory() -> dict:
549
+ """Read-only stock view over the seeded inventory JSON (low-stock reads are real)."""
550
+ return inventory_data()
551
+
552
+
553
  @app.get("/web/{path:path}")
554
  def static_files(path: str):
555
  target = (WEB / path).resolve()
556
  if WEB.resolve() in target.parents and target.is_file():
557
+ # `no-cache` = revalidate every load (cheap 304 if unchanged), so a JS/CSS edit is
558
+ # always picked up — never a stale-cached frontend after a code change.
559
+ return FileResponse(target, headers={"Cache-Control": "no-cache"})
560
  return HTMLResponse("not found", status_code=404)
561
 
562
 
quillwright/theme.css CHANGED
@@ -1,17 +1,90 @@
1
- /* FieldForge — industrial precision. Source: docs/design/frontend-core-workspace.md */
2
- @import url('https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@700;800;900&family=Inter:wght@400;700&family=JetBrains+Mono:wght@500&family=Material+Symbols+Outlined&display=swap');
3
- :root { --ff-primary:#964900; --ff-accent:#f57c00; --ff-log-bg:#10191E; --ff-log-border:#1C2931; --ff-ok:#22c55e; }
4
- .ff-title { font-family:'Hanken Grotesk'; font-weight:900; color:var(--ff-primary); font-size:24px; letter-spacing:-0.01em; }
5
- .ff-log { background:var(--ff-log-bg); color:#cdd6db; font-family:'JetBrains Mono',monospace; font-size:13px; padding:24px; min-height:420px; border-radius:8px; }
6
- .ff-step { display:flex; gap:12px; padding:8px 0; align-items:flex-start; }
7
- .ff-step-body p { color:#fff; opacity:.92; margin:0; }
8
- .ff-badge { display:inline-block; margin-top:4px; color:var(--ff-accent); font-size:11px; }
9
- .ff-ok { color:var(--ff-ok); font-variation-settings:'FILL' 1; }
10
- .ff-err { color:#ba1a1a; }
11
- .ff-dot { width:8px; height:8px; border-radius:50%; background:var(--ff-accent); display:inline-block; margin-top:6px; animation:ff-pulse 2s infinite; }
12
- .ff-wait { color:#546E7A; }
13
- .terminal-cursor { display:inline-block; width:8px; height:16px; background:var(--ff-accent); margin-left:4px; vertical-align:middle; animation:ff-blink 1s step-end infinite; }
14
- @keyframes ff-blink { 50% { opacity:0; } }
15
- @keyframes ff-pulse { 50% { opacity:.4; transform:scale(1.15); } }
16
- .material-symbols-outlined { font-family:'Material Symbols Outlined'; font-size:20px; vertical-align:middle; }
17
- .ff-pane-head { font-family:'Hanken Grotesk'; font-weight:700; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Quillwright — industrial precision. Source: docs/design/frontend-core-workspace.md */
2
+ @import url("https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@700;800;900&family=Inter:wght@400;700&family=JetBrains+Mono:wght@500&family=Material+Symbols+Outlined&display=swap");
3
+ :root {
4
+ --ff-primary: #964900;
5
+ --ff-accent: #f57c00;
6
+ --ff-log-bg: #10191e;
7
+ --ff-log-border: #1c2931;
8
+ --ff-ok: #22c55e;
9
+ }
10
+ .ff-title {
11
+ font-family: "Hanken Grotesk";
12
+ font-weight: 900;
13
+ color: var(--ff-primary);
14
+ font-size: 24px;
15
+ letter-spacing: -0.01em;
16
+ }
17
+ .ff-log {
18
+ background: var(--ff-log-bg);
19
+ color: #cdd6db;
20
+ font-family: "JetBrains Mono", monospace;
21
+ font-size: 13px;
22
+ padding: 24px;
23
+ min-height: 420px;
24
+ border-radius: 8px;
25
+ }
26
+ .ff-step {
27
+ display: flex;
28
+ gap: 12px;
29
+ padding: 8px 0;
30
+ align-items: flex-start;
31
+ }
32
+ .ff-step-body p {
33
+ color: #fff;
34
+ opacity: 0.92;
35
+ margin: 0;
36
+ }
37
+ .ff-badge {
38
+ display: inline-block;
39
+ margin-top: 4px;
40
+ color: var(--ff-accent);
41
+ font-size: 11px;
42
+ }
43
+ .ff-ok {
44
+ color: var(--ff-ok);
45
+ font-variation-settings: "FILL" 1;
46
+ }
47
+ .ff-err {
48
+ color: #ba1a1a;
49
+ }
50
+ .ff-dot {
51
+ width: 8px;
52
+ height: 8px;
53
+ border-radius: 50%;
54
+ background: var(--ff-accent);
55
+ display: inline-block;
56
+ margin-top: 6px;
57
+ animation: ff-pulse 2s infinite;
58
+ }
59
+ .ff-wait {
60
+ color: #546e7a;
61
+ }
62
+ .terminal-cursor {
63
+ display: inline-block;
64
+ width: 8px;
65
+ height: 16px;
66
+ background: var(--ff-accent);
67
+ margin-left: 4px;
68
+ vertical-align: middle;
69
+ animation: ff-blink 1s step-end infinite;
70
+ }
71
+ @keyframes ff-blink {
72
+ 50% {
73
+ opacity: 0;
74
+ }
75
+ }
76
+ @keyframes ff-pulse {
77
+ 50% {
78
+ opacity: 0.4;
79
+ transform: scale(1.15);
80
+ }
81
+ }
82
+ .material-symbols-outlined {
83
+ font-family: "Material Symbols Outlined";
84
+ font-size: 20px;
85
+ vertical-align: middle;
86
+ }
87
+ .ff-pane-head {
88
+ font-family: "Hanken Grotesk";
89
+ font-weight: 700;
90
+ }
quillwright/thread.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Refinement Thread: the sanitized, resumable record of post-forge chat turns
2
+ for one Saved Estimate (ADR-0013).
3
+
4
+ Each turn is {"message": <human text>, "op": <terse dollar-free intent>}. The
5
+ model-facing history is built from `op` ONLY — never dollar figures — so resuming
6
+ the conversation can never feed a stale number back to the model (Facts-from-Tools,
7
+ ADR-0004). Numbers always come live from the current Line Items, not from here.
8
+
9
+ Compaction is deterministic (code folds old ops into one line); no model summarizes
10
+ the thread — a second instance of Facts-from-Tools.
11
+ """
12
+
13
+
14
+ def append_turn(thread: list[dict], message: str, op: str) -> list[dict]:
15
+ """Return a new thread with one turn appended (does not mutate the input)."""
16
+ return [*thread, {"message": message, "op": op}]
17
+
18
+
19
+ def sanitized_history(thread: list[dict]) -> str:
20
+ """Model-facing history: the ops only, one per line. No dollars by construction."""
21
+ return "\n".join(f"- {t['op']}" for t in thread if t.get("op"))
22
+
23
+
24
+ def compact(thread: list[dict], keep_last: int = 6) -> str:
25
+ """Bounded model-facing history: fold all but the last `keep_last` ops into one
26
+ mechanical 'earlier in this estimate: …' line; keep the tail verbatim."""
27
+ if len(thread) <= keep_last:
28
+ return sanitized_history(thread)
29
+ head, tail = thread[:-keep_last], thread[-keep_last:]
30
+ folded = "; ".join(t["op"] for t in head if t.get("op"))
31
+ lines = [f"- earlier in this estimate: {folded}"]
32
+ lines += [f"- {t['op']}" for t in tail if t.get("op")]
33
+ return "\n".join(lines)
quillwright/tools.py CHANGED
@@ -1,6 +1,9 @@
1
  import ast
2
  import json
3
  import operator
 
 
 
4
  from quillwright.models import Observation, LineItem
5
  from quillwright.catalog import Catalog
6
  from quillwright.resolver import Model
@@ -55,17 +58,48 @@ _PERCEIVE_PROMPT = (
55
  )
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  def perceive(image_path: str, model: Model) -> list[Observation]:
59
  # Vision-capable backends accept image_path; text stubs ignore the kwarg.
60
  try:
61
  raw = model.generate(_PERCEIVE_PROMPT, image_path=image_path)
62
  except TypeError:
63
  raw = model.generate(f"List observations as JSON for image: {image_path}")
64
- try:
65
- data = json.loads(raw)
66
- except json.JSONDecodeError:
67
  return []
68
- return [Observation(**o) for o in data]
 
 
 
 
 
 
 
 
69
 
70
 
71
  def draft_line_item(
 
1
  import ast
2
  import json
3
  import operator
4
+
5
+ from pydantic import ValidationError
6
+
7
  from quillwright.models import Observation, LineItem
8
  from quillwright.catalog import Catalog
9
  from quillwright.resolver import Model
 
58
  )
59
 
60
 
61
+ def _extract_json_array(raw: str):
62
+ """Pull a JSON array out of a model reply, or return None.
63
+
64
+ Vision models (MiniCPM-V) routinely wrap the array in a ```json fence with a prose
65
+ preamble ("Based on my analysis, here is …") instead of replying with ONLY the array
66
+ as asked. Parsing `raw` directly then fails and we'd silently see 0 observations. So:
67
+ try the whole string first, then the first balanced [...] slice we can find.
68
+ """
69
+ try:
70
+ parsed = json.loads(raw)
71
+ return parsed if isinstance(parsed, list) else None
72
+ except (json.JSONDecodeError, TypeError):
73
+ pass
74
+ start = raw.find("[")
75
+ end = raw.rfind("]")
76
+ if start == -1 or end <= start:
77
+ return None
78
+ try:
79
+ parsed = json.loads(raw[start : end + 1])
80
+ return parsed if isinstance(parsed, list) else None
81
+ except json.JSONDecodeError:
82
+ return None
83
+
84
+
85
  def perceive(image_path: str, model: Model) -> list[Observation]:
86
  # Vision-capable backends accept image_path; text stubs ignore the kwarg.
87
  try:
88
  raw = model.generate(_PERCEIVE_PROMPT, image_path=image_path)
89
  except TypeError:
90
  raw = model.generate(f"List observations as JSON for image: {image_path}")
91
+ data = _extract_json_array(raw)
92
+ if data is None:
 
93
  return []
94
+ # Skip rows that don't validate (e.g. a kind outside the allowed set) rather than
95
+ # letting one bad row drop the whole estimate to zero observations.
96
+ obs = []
97
+ for o in data:
98
+ try:
99
+ obs.append(Observation(**o))
100
+ except (TypeError, ValidationError):
101
+ continue
102
+ return obs
103
 
104
 
105
  def draft_line_item(
quillwright/web/css/pages.css ADDED
@@ -0,0 +1,217 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Secondary pages (Dashboard / Active Jobs / Inventory) — shares tokens with
2
+ theme.css and the .btn/table/sidebar idioms from workspace.css. */
3
+
4
+ /* App shell: same sidebar as the workspace, scrollable content column. */
5
+ .app {
6
+ display: flex;
7
+ min-height: 100vh;
8
+ }
9
+ .content {
10
+ flex: 1 1 auto;
11
+ min-width: 0;
12
+ display: flex;
13
+ flex-direction: column;
14
+ }
15
+
16
+ /* Page header. */
17
+ .page-head {
18
+ padding: 32px;
19
+ border-bottom: 1px solid var(--outline-variant);
20
+ background: var(--surface);
21
+ }
22
+ .page-head h1 {
23
+ font-family: var(--font-head);
24
+ font-weight: 800;
25
+ font-size: 32px;
26
+ letter-spacing: -0.02em;
27
+ margin: 0;
28
+ color: var(--on-surface);
29
+ }
30
+ .page-head p {
31
+ margin: 6px 0 0;
32
+ color: var(--secondary);
33
+ }
34
+ .page-body {
35
+ padding: 32px;
36
+ display: flex;
37
+ flex-direction: column;
38
+ gap: 24px;
39
+ }
40
+
41
+ /* KPI cards. */
42
+ .kpis {
43
+ display: grid;
44
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
45
+ gap: 16px;
46
+ }
47
+ .kpi {
48
+ background: var(--surface-container-lowest);
49
+ border: 1px solid var(--outline-variant);
50
+ border-radius: 12px;
51
+ padding: 24px;
52
+ }
53
+ .kpi .label {
54
+ font-family: var(--font-head);
55
+ font-size: 12px;
56
+ letter-spacing: 0.05em;
57
+ text-transform: uppercase;
58
+ color: var(--secondary);
59
+ font-weight: 700;
60
+ margin: 0;
61
+ }
62
+ .kpi .value {
63
+ font-family: var(--font-head);
64
+ font-size: 40px;
65
+ font-weight: 800;
66
+ letter-spacing: -0.02em;
67
+ margin: 8px 0 0;
68
+ color: var(--on-surface);
69
+ }
70
+ .kpi .sub {
71
+ margin: 6px 0 0;
72
+ font-size: 14px;
73
+ color: var(--secondary);
74
+ }
75
+ .kpi--dark {
76
+ background: #2d3133;
77
+ border-color: #2d3133;
78
+ }
79
+ .kpi--dark .label {
80
+ color: #bbc8d0;
81
+ }
82
+ .kpi--dark .value,
83
+ .kpi--dark .sub {
84
+ color: #fff;
85
+ }
86
+ .kpi--alert {
87
+ border-left: 4px solid var(--error);
88
+ }
89
+ .kpi--alert .label,
90
+ .kpi--alert .value {
91
+ color: var(--error);
92
+ }
93
+
94
+ /* Card wrapper for tables / lists. */
95
+ .card {
96
+ background: var(--surface-container-lowest);
97
+ border: 1px solid var(--outline-variant);
98
+ border-radius: 12px;
99
+ overflow: hidden;
100
+ }
101
+ .card-head {
102
+ display: flex;
103
+ align-items: center;
104
+ justify-content: space-between;
105
+ padding: 18px 24px;
106
+ border-bottom: 1px solid var(--outline-variant);
107
+ background: var(--surface-container-low);
108
+ }
109
+ .card-head h2 {
110
+ font-family: var(--font-head);
111
+ font-size: 18px;
112
+ margin: 0;
113
+ color: var(--on-surface);
114
+ }
115
+
116
+ /* Tables on pages have no rounded inner border (the card supplies it). */
117
+ .card table {
118
+ border: 0;
119
+ border-radius: 0;
120
+ }
121
+
122
+ /* Chips / badges. */
123
+ .chip {
124
+ display: inline-flex;
125
+ align-items: center;
126
+ gap: 6px;
127
+ padding: 3px 10px;
128
+ border-radius: 999px;
129
+ font-size: 11px;
130
+ font-weight: 700;
131
+ text-transform: uppercase;
132
+ letter-spacing: 0.03em;
133
+ }
134
+ .chip--cat {
135
+ background: var(--tertiary-fixed);
136
+ color: var(--on-tertiary-container);
137
+ }
138
+ .chip--ok {
139
+ background: rgba(34, 197, 94, 0.12);
140
+ color: #15803d;
141
+ }
142
+ .chip--low {
143
+ background: #ffdad6;
144
+ color: #93000a;
145
+ }
146
+ .mono {
147
+ font-family: var(--font-mono);
148
+ font-size: 13px;
149
+ }
150
+
151
+ /* Stock bar. */
152
+ .stock {
153
+ display: flex;
154
+ flex-direction: column;
155
+ gap: 4px;
156
+ width: 140px;
157
+ }
158
+ .stock .meter {
159
+ height: 6px;
160
+ background: var(--surface-container-high);
161
+ border-radius: 999px;
162
+ overflow: hidden;
163
+ }
164
+ .stock .meter span {
165
+ display: block;
166
+ height: 100%;
167
+ background: var(--primary-container);
168
+ }
169
+ .stock.is-low .meter span {
170
+ background: var(--error);
171
+ }
172
+ .stock .nums {
173
+ display: flex;
174
+ justify-content: space-between;
175
+ font-size: 11px;
176
+ font-weight: 700;
177
+ }
178
+
179
+ /* Empty state. */
180
+ .empty {
181
+ padding: 48px 24px;
182
+ text-align: center;
183
+ color: var(--secondary);
184
+ }
185
+ .empty .material-symbols-outlined {
186
+ font-size: 40px;
187
+ color: var(--outline);
188
+ display: block;
189
+ margin: 0 0 8px;
190
+ }
191
+ .empty a {
192
+ color: var(--primary);
193
+ font-weight: 700;
194
+ }
195
+
196
+ /* Filter row for the catalog. */
197
+ .filters {
198
+ display: flex;
199
+ gap: 8px;
200
+ flex-wrap: wrap;
201
+ }
202
+ .filter {
203
+ padding: 4px 12px;
204
+ border-radius: 999px;
205
+ border: 1px solid var(--outline-variant);
206
+ background: transparent;
207
+ color: var(--secondary);
208
+ font-size: 12px;
209
+ font-weight: 700;
210
+ cursor: pointer;
211
+ font-family: var(--font-body);
212
+ }
213
+ .filter.active {
214
+ border-color: var(--primary);
215
+ color: var(--on-primary);
216
+ background: var(--primary);
217
+ }
quillwright/web/css/theme.css CHANGED
@@ -8,6 +8,7 @@
8
 
9
  --background: #f7fafc;
10
  --surface: #f7fafc;
 
11
  --surface-container-low: #f1f4f6;
12
  --surface-container: #ebeef0;
13
  --surface-container-high: #e5e9eb;
@@ -16,6 +17,7 @@
16
  --secondary: #546067;
17
  --outline: #8b7263;
18
  --outline-variant: #dec1af;
 
19
 
20
  --tertiary-fixed: #d4e5ef;
21
  --tertiary-container: #8fa0a9;
 
8
 
9
  --background: #f7fafc;
10
  --surface: #f7fafc;
11
+ --surface-container-lowest: #ffffff;
12
  --surface-container-low: #f1f4f6;
13
  --surface-container: #ebeef0;
14
  --surface-container-high: #e5e9eb;
 
17
  --secondary: #546067;
18
  --outline: #8b7263;
19
  --outline-variant: #dec1af;
20
+ --error: #ba1a1a;
21
 
22
  --tertiary-fixed: #d4e5ef;
23
  --tertiary-container: #8fa0a9;
quillwright/web/css/workspace.css CHANGED
@@ -15,9 +15,21 @@
15
  border-right: 1px solid var(--outline-variant);
16
  }
17
  .sidebar .brand {
 
 
 
18
  padding: 0 8px;
19
  margin-bottom: 40px;
20
  }
 
 
 
 
 
 
 
 
 
21
  .sidebar .brand h2 {
22
  font-family: var(--font-head);
23
  font-weight: 800;
@@ -44,6 +56,15 @@
44
  color: var(--secondary);
45
  font-weight: 400;
46
  }
 
 
 
 
 
 
 
 
 
47
  .nav-link.active {
48
  color: var(--primary);
49
  font-weight: 700;
@@ -134,6 +155,7 @@
134
  display: flex;
135
  align-items: center;
136
  justify-content: space-between;
 
137
  padding: 20px 24px;
138
  border-bottom: 1px solid var(--outline-variant);
139
  }
@@ -149,102 +171,97 @@
149
  padding: 24px;
150
  }
151
 
152
- /* Digital Apprentice header. */
 
153
  .apprentice {
154
  display: flex;
155
  align-items: center;
156
- gap: 10px;
 
 
 
 
 
157
  }
158
  .apprentice .material-symbols-outlined {
159
  color: var(--primary);
160
  animation: pulse 2s infinite;
161
  }
162
- .live {
163
- display: flex;
 
 
164
  align-items: center;
165
- gap: 8px;
 
 
 
 
 
 
166
  color: var(--secondary);
167
- font-size: 14px;
168
  }
169
- .live-dot {
170
- width: 8px;
171
- height: 8px;
172
  border-radius: 50%;
173
- background: #22c55e;
174
- box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
175
- }
176
-
177
- /* Apprentice step cards. */
178
- .steps {
179
- display: flex;
180
- flex-direction: column;
181
- gap: 16px;
182
  }
183
- .step-card {
184
- display: flex;
185
- gap: 14px;
186
- background: var(--surface-container-lowest);
187
- border: 1px solid var(--outline-variant);
188
- border-radius: 12px;
189
- padding: 16px;
190
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
191
  }
192
- .step-card.working {
193
- background: #fff8f2;
194
- border-color: rgba(150, 73, 0, 0.2);
 
195
  }
196
- .step-rail {
197
- display: flex;
198
- flex-direction: column;
199
- align-items: center;
200
  }
201
- .step-rail .material-symbols-outlined {
202
- color: #22c55e;
203
- font-variation-settings: "FILL" 1;
204
  }
205
- .step-rail .working-dot {
206
- width: 24px;
207
- height: 24px;
208
- border-radius: 50%;
209
- background: var(--primary-container);
210
  display: flex;
211
  align-items: center;
212
- justify-content: center;
 
 
 
213
  }
214
- .step-rail .working-dot::after {
215
- content: "";
 
216
  width: 8px;
217
  height: 8px;
218
  border-radius: 50%;
219
- background: #fff;
 
 
 
220
  }
221
- .step-body .title {
222
- font-weight: 700;
223
- color: var(--on-surface);
224
- margin: 0;
225
- }
226
- .step-card.working .title {
227
- color: var(--primary);
228
- }
229
- .step-body .detail {
230
- font-size: 14px;
231
- color: var(--secondary);
232
- margin: 2px 0 0;
233
- }
234
- .step-items {
235
- margin: 6px 0 0;
236
- padding-left: 18px;
237
- font-size: 14px;
238
- color: var(--secondary);
239
  }
240
- .step-items li {
241
- margin: 2px 0;
 
242
  }
243
- .step-empty {
244
- color: var(--secondary);
245
- font-size: 14px;
246
  }
247
 
 
 
 
248
  /* Estimate table. */
249
  .est-actions {
250
  display: flex;
@@ -263,10 +280,17 @@
263
  cursor: pointer;
264
  font-family: var(--font-body);
265
  color: var(--on-surface);
 
 
 
 
266
  }
267
  .btn:hover {
268
  background: var(--surface-container);
269
  }
 
 
 
270
  .btn--primary {
271
  background: var(--primary);
272
  color: var(--on-primary);
@@ -276,6 +300,15 @@
276
  .btn--primary:hover {
277
  opacity: 0.92;
278
  }
 
 
 
 
 
 
 
 
 
279
  .btn .material-symbols-outlined {
280
  font-size: 18px;
281
  }
@@ -316,8 +349,16 @@ td[contenteditable]:focus {
316
  outline: 2px solid var(--primary-container);
317
  background: #fff;
318
  }
 
 
 
 
 
 
 
 
319
 
320
- /* Agent-Pause card. */
321
  .pause-card {
322
  margin-top: 24px;
323
  background: var(--tertiary-fixed);
@@ -326,6 +367,17 @@ td[contenteditable]:focus {
326
  padding: 24px;
327
  display: flex;
328
  gap: 16px;
 
 
 
 
 
 
 
 
 
 
 
329
  }
330
  .pause-card .bot {
331
  background: var(--on-tertiary-container);
@@ -344,6 +396,60 @@ td[contenteditable]:focus {
344
  gap: 12px;
345
  }
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  /* Financial summary. */
348
  .summary {
349
  display: flex;
@@ -417,3 +523,642 @@ select {
417
  transform: scale(1.1);
418
  }
419
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  border-right: 1px solid var(--outline-variant);
16
  }
17
  .sidebar .brand {
18
+ display: flex;
19
+ align-items: center;
20
+ gap: 12px;
21
  padding: 0 8px;
22
  margin-bottom: 40px;
23
  }
24
+ .sidebar .brand-logo {
25
+ width: 44px;
26
+ height: 44px;
27
+ object-fit: contain;
28
+ flex: none;
29
+ }
30
+ .sidebar .brand-text {
31
+ min-width: 0;
32
+ }
33
  .sidebar .brand h2 {
34
  font-family: var(--font-head);
35
  font-weight: 800;
 
56
  color: var(--secondary);
57
  font-weight: 400;
58
  }
59
+ .nav-link {
60
+ transition:
61
+ background 0.15s ease,
62
+ color 0.15s ease;
63
+ }
64
+ .nav-link:hover {
65
+ background: var(--surface-container-high);
66
+ color: var(--on-surface);
67
+ }
68
  .nav-link.active {
69
  color: var(--primary);
70
  font-weight: 700;
 
155
  display: flex;
156
  align-items: center;
157
  justify-content: space-between;
158
+ gap: 12px;
159
  padding: 20px 24px;
160
  border-bottom: 1px solid var(--outline-variant);
161
  }
 
171
  padding: 24px;
172
  }
173
 
174
+ /* Digital Apprentice header. The badge wraps onto its own line under the title
175
+ when the pane is narrow, so it never collides with the run-state label. */
176
  .apprentice {
177
  display: flex;
178
  align-items: center;
179
+ flex-wrap: wrap;
180
+ gap: 8px 10px;
181
+ min-width: 0;
182
+ }
183
+ .apprentice h2 {
184
+ margin-right: 2px;
185
  }
186
  .apprentice .material-symbols-outlined {
187
  color: var(--primary);
188
  animation: pulse 2s infinite;
189
  }
190
+ /* Model-mode badge: which models are actually running (stub/local/modal/mixed)
191
+ + per-role labels. Honest at-a-glance answer to "am I on the real models?". */
192
+ .model-badge {
193
+ display: inline-flex;
194
  align-items: center;
195
+ gap: 6px;
196
+ padding: 3px 9px;
197
+ border: 1px solid var(--outline-variant);
198
+ border-radius: 999px;
199
+ font-size: 11px;
200
+ font-weight: 600;
201
+ letter-spacing: 0.02em;
202
  color: var(--secondary);
203
+ background: var(--surface);
204
  }
205
+ .model-badge-dot {
206
+ width: 7px;
207
+ height: 7px;
208
  border-radius: 50%;
209
+ background: var(--outline-variant);
210
+ flex: none;
 
 
 
 
 
 
 
211
  }
212
+ .model-badge-roles {
213
+ font-weight: 400;
214
+ color: var(--secondary);
215
+ font-family: var(--font-mono, monospace);
216
+ font-size: 10px;
 
 
 
217
  }
218
+ /* Dot color per mode: stub = neutral, local = green (on-device), modal = orange
219
+ (hosted accent), mixed = amber (a spread of backends). */
220
+ .model-badge[data-mode="local"] .model-badge-dot {
221
+ background: var(--ok);
222
  }
223
+ .model-badge[data-mode="modal"] .model-badge-dot {
224
+ background: var(--primary);
 
 
225
  }
226
+ .model-badge[data-mode="mixed"] .model-badge-dot {
227
+ background: #d9920a;
 
228
  }
229
+
230
+ .live {
 
 
 
231
  display: flex;
232
  align-items: center;
233
+ gap: 8px;
234
+ flex: none;
235
+ color: var(--secondary);
236
+ font-size: 14px;
237
  }
238
+ /* The dot reflects the run state (data-state on .live) instead of always glowing
239
+ green "Idle" with a live-green dot read as a lie. */
240
+ .live-dot {
241
  width: 8px;
242
  height: 8px;
243
  border-radius: 50%;
244
+ background: var(--outline-variant);
245
+ transition:
246
+ background 0.25s ease,
247
+ box-shadow 0.25s ease;
248
  }
249
+ .live[data-state="working"] .live-dot {
250
+ background: var(--primary-container);
251
+ animation: dot-breathe 1.1s ease-in-out infinite;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
  }
253
+ .live[data-state="needs-you"] .live-dot {
254
+ background: var(--on-tertiary-container);
255
+ box-shadow: 0 0 8px rgba(39, 55, 63, 0.5);
256
  }
257
+ .live[data-state="done"] .live-dot {
258
+ background: var(--ok);
259
+ box-shadow: 0 0 8px rgba(34, 197, 94, 0.6);
260
  }
261
 
262
+ /* (The original "Apprentice step cards" block lived here — fully superseded by the
263
+ unified-stream section below; removed rather than shadowed by the cascade.) */
264
+
265
  /* Estimate table. */
266
  .est-actions {
267
  display: flex;
 
280
  cursor: pointer;
281
  font-family: var(--font-body);
282
  color: var(--on-surface);
283
+ transition:
284
+ background 0.15s ease,
285
+ opacity 0.15s ease,
286
+ transform 0.12s ease;
287
  }
288
  .btn:hover {
289
  background: var(--surface-container);
290
  }
291
+ .btn:active {
292
+ transform: scale(0.97);
293
+ }
294
  .btn--primary {
295
  background: var(--primary);
296
  color: var(--on-primary);
 
300
  .btn--primary:hover {
301
  opacity: 0.92;
302
  }
303
+ /* Forge while a run streams: held down, bolt pulsing — also blocks a double-forge. */
304
+ .btn:disabled {
305
+ opacity: 0.55;
306
+ cursor: not-allowed;
307
+ transform: none;
308
+ }
309
+ #forge-btn:disabled .material-symbols-outlined {
310
+ animation: pulse 1.2s ease-in-out infinite;
311
+ }
312
  .btn .material-symbols-outlined {
313
  font-size: 18px;
314
  }
 
349
  outline: 2px solid var(--primary-container);
350
  background: #fff;
351
  }
352
+ /* Empty estimate: say so instead of a header floating over nothing. */
353
+ .table-empty td {
354
+ text-align: center;
355
+ font-family: var(--font-mono);
356
+ font-size: 13px;
357
+ color: var(--secondary);
358
+ padding: 28px 16px;
359
+ }
360
 
361
+ /* Agent-Pause card. Entrance animation re-fires on each display:none -> flex. */
362
  .pause-card {
363
  margin-top: 24px;
364
  background: var(--tertiary-fixed);
 
367
  padding: 24px;
368
  display: flex;
369
  gap: 16px;
370
+ animation: step-in 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
371
+ }
372
+ .pause-card .pause-body {
373
+ flex: 1;
374
+ }
375
+ .pause-card .pause-price {
376
+ width: 120px;
377
+ padding: 8px;
378
+ border: 1px solid var(--outline);
379
+ border-radius: 8px;
380
+ font-family: var(--font-body);
381
  }
382
  .pause-card .bot {
383
  background: var(--on-tertiary-container);
 
396
  gap: 12px;
397
  }
398
 
399
+ /* Document Capture: Proposed-Line-Items confirm card (rides the Agent-Pause surface). */
400
+ .doc-confirm {
401
+ flex: 1;
402
+ min-width: 0;
403
+ }
404
+ .doc-items {
405
+ display: flex;
406
+ flex-direction: column;
407
+ gap: 8px;
408
+ margin-bottom: 16px;
409
+ }
410
+ .doc-item {
411
+ display: grid;
412
+ grid-template-columns: auto minmax(0, 1fr) 72px 96px;
413
+ gap: 12px;
414
+ align-items: center;
415
+ background: var(--surface-container-lowest);
416
+ border: 1px solid var(--outline-variant);
417
+ border-radius: 8px;
418
+ padding: 8px 12px;
419
+ cursor: pointer;
420
+ transition: opacity 0.18s ease;
421
+ animation: row-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
422
+ animation-delay: var(--doc-delay, 0ms);
423
+ }
424
+ .doc-item:has(input[type="checkbox"]:not(:checked)) {
425
+ opacity: 0.45;
426
+ }
427
+ .doc-item .doc-desc {
428
+ display: flex;
429
+ flex-direction: column;
430
+ font-weight: 700;
431
+ color: var(--on-surface);
432
+ }
433
+ .doc-item .doc-desc small {
434
+ font-family: var(--font-mono);
435
+ font-size: 11px;
436
+ font-weight: 400;
437
+ color: var(--secondary);
438
+ overflow: hidden;
439
+ text-overflow: ellipsis;
440
+ white-space: nowrap;
441
+ }
442
+ .doc-item input[type="number"] {
443
+ width: 100%;
444
+ padding: 6px 8px;
445
+ border: 1px solid var(--outline);
446
+ border-radius: 6px;
447
+ font-family: var(--font-body);
448
+ }
449
+ .doc-item input[type="checkbox"] {
450
+ accent-color: var(--primary-container);
451
+ }
452
+
453
  /* Financial summary. */
454
  .summary {
455
  display: flex;
 
523
  transform: scale(1.1);
524
  }
525
  }
526
+
527
+ /* ============================================================
528
+ Apprentice pane — unified stream (trace + chat) + motion
529
+ ============================================================ */
530
+
531
+ /* The scrolling stream holds the step cards then the chat turns. */
532
+ .stream {
533
+ flex: 1 1 auto;
534
+ min-height: 0;
535
+ overflow-y: auto;
536
+ padding: 24px 20px;
537
+ }
538
+ .steps {
539
+ display: flex;
540
+ flex-direction: column;
541
+ gap: 0; /* the connector rail bridges the gap between cards */
542
+ }
543
+
544
+ /* --- Step cards: JetBrains-Mono terminal feel with a connector rail --- */
545
+ .step-card {
546
+ display: flex;
547
+ gap: 14px;
548
+ background: var(--surface-container-lowest);
549
+ border: 1px solid var(--outline-variant);
550
+ border-radius: 12px;
551
+ padding: 16px;
552
+ margin-bottom: 16px;
553
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
554
+ }
555
+ .step-card.working {
556
+ background: #fff8f2;
557
+ border-color: rgba(150, 73, 0, 0.22);
558
+ }
559
+
560
+ /* Rail: marker + a line that drops toward the next card. */
561
+ .step-rail {
562
+ display: flex;
563
+ flex-direction: column;
564
+ align-items: center;
565
+ flex: 0 0 auto;
566
+ }
567
+ .step-rail .check {
568
+ color: var(--ok);
569
+ font-variation-settings: "FILL" 1;
570
+ font-size: 22px;
571
+ }
572
+ .step-rail .rail-line {
573
+ flex: 1 1 auto;
574
+ width: 2px;
575
+ margin-top: 6px;
576
+ background: var(--outline-variant);
577
+ border-radius: 2px;
578
+ }
579
+ .step-card:last-of-type .step-rail .rail-line {
580
+ display: none;
581
+ }
582
+ .step-rail .working-dot {
583
+ width: 22px;
584
+ height: 22px;
585
+ border-radius: 50%;
586
+ background: var(--primary-container);
587
+ display: flex;
588
+ align-items: center;
589
+ justify-content: center;
590
+ animation: dot-breathe 1.1s ease-in-out infinite;
591
+ }
592
+ .step-rail .working-dot::after {
593
+ content: "";
594
+ width: 7px;
595
+ height: 7px;
596
+ border-radius: 50%;
597
+ background: #fff;
598
+ }
599
+
600
+ .step-body {
601
+ flex: 1 1 auto;
602
+ min-width: 0;
603
+ }
604
+ .step-body .title {
605
+ font-family: var(--font-head);
606
+ font-weight: 800;
607
+ font-size: 16px;
608
+ color: var(--on-surface);
609
+ margin: 0 0 4px;
610
+ }
611
+ .step-card.working .title {
612
+ color: var(--primary);
613
+ }
614
+ /* Detail + item lines use the mono face — the "console output" look. */
615
+ .step-body .detail,
616
+ .step-items {
617
+ font-family: var(--font-mono);
618
+ font-size: 13px;
619
+ line-height: 1.5;
620
+ color: var(--secondary);
621
+ margin: 0;
622
+ }
623
+ .step-items {
624
+ margin: 6px 0 0;
625
+ padding-left: 18px;
626
+ }
627
+ .step-items li {
628
+ margin: 3px 0;
629
+ }
630
+ .step-card.working .detail {
631
+ color: var(--on-primary-container);
632
+ }
633
+ .step-empty {
634
+ font-family: var(--font-mono);
635
+ font-size: 13px;
636
+ color: var(--secondary);
637
+ }
638
+
639
+ /* --- Chat turns live in the same stream, below the steps --- */
640
+ .chat-msg {
641
+ display: flex;
642
+ margin-bottom: 12px;
643
+ animation: msg-in 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;
644
+ }
645
+ .chat-msg.user {
646
+ justify-content: flex-end;
647
+ }
648
+ .chat-msg .bubble {
649
+ max-width: 82%;
650
+ padding: 11px 14px;
651
+ border-radius: 14px;
652
+ font-size: 14px;
653
+ line-height: 1.45;
654
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
655
+ }
656
+ .chat-msg.bot .bubble {
657
+ background: var(--surface-container-lowest);
658
+ border: 1px solid var(--outline-variant);
659
+ border-bottom-left-radius: 4px;
660
+ color: var(--on-surface);
661
+ }
662
+ .chat-msg.user .bubble {
663
+ background: var(--primary);
664
+ color: var(--on-primary);
665
+ border-bottom-right-radius: 4px;
666
+ }
667
+ /* A thin divider introduces the conversation once the trace is done. */
668
+ .chat-divider {
669
+ display: flex;
670
+ align-items: center;
671
+ gap: 10px;
672
+ margin: 4px 0 16px;
673
+ color: var(--secondary);
674
+ font-family: var(--font-mono);
675
+ font-size: 11px;
676
+ letter-spacing: 0.04em;
677
+ text-transform: uppercase;
678
+ }
679
+ .chat-divider::before,
680
+ .chat-divider::after {
681
+ content: "";
682
+ flex: 1;
683
+ height: 1px;
684
+ background: var(--outline-variant);
685
+ }
686
+
687
+ /* Typing indicator. */
688
+ .typing .bubble {
689
+ display: inline-flex;
690
+ gap: 5px;
691
+ align-items: center;
692
+ }
693
+ .typing .bubble i {
694
+ width: 7px;
695
+ height: 7px;
696
+ border-radius: 50%;
697
+ background: var(--secondary);
698
+ display: inline-block;
699
+ animation: typing 1.2s ease-in-out infinite;
700
+ }
701
+ .typing .bubble i:nth-child(2) {
702
+ animation-delay: 0.15s;
703
+ }
704
+ .typing .bubble i:nth-child(3) {
705
+ animation-delay: 0.3s;
706
+ }
707
+
708
+ /* --- Docked chat input at the bottom of the pane --- */
709
+ .chat-input {
710
+ flex: 0 0 auto;
711
+ display: flex;
712
+ gap: 8px;
713
+ padding: 14px 16px;
714
+ border-top: 1px solid var(--outline-variant);
715
+ background: var(--surface);
716
+ }
717
+ .chat-input input {
718
+ flex: 1;
719
+ padding: 12px 16px;
720
+ border: 1px solid var(--outline-variant);
721
+ border-radius: 999px;
722
+ font-family: var(--font-body);
723
+ font-size: 14px;
724
+ background: var(--surface-container-low);
725
+ transition:
726
+ border-color 0.18s ease,
727
+ box-shadow 0.18s ease,
728
+ background 0.18s ease;
729
+ }
730
+ .chat-input input:focus {
731
+ outline: none;
732
+ border-color: var(--primary);
733
+ background: #fff;
734
+ box-shadow: 0 0 0 3px rgba(150, 73, 0, 0.12);
735
+ }
736
+ .chat-input input:disabled {
737
+ opacity: 0.6;
738
+ cursor: not-allowed;
739
+ }
740
+ .chat-send {
741
+ flex: 0 0 auto;
742
+ width: 44px;
743
+ height: 44px;
744
+ border: 0;
745
+ border-radius: 50%;
746
+ background: var(--primary);
747
+ color: var(--on-primary);
748
+ cursor: pointer;
749
+ display: inline-flex;
750
+ align-items: center;
751
+ justify-content: center;
752
+ transition:
753
+ transform 0.12s ease,
754
+ opacity 0.18s ease;
755
+ }
756
+ .chat-send:hover:not(:disabled) {
757
+ opacity: 0.92;
758
+ }
759
+ .chat-send:active:not(:disabled) {
760
+ transform: scale(0.92);
761
+ }
762
+ .chat-send:disabled {
763
+ opacity: 0.4;
764
+ cursor: not-allowed;
765
+ }
766
+
767
+ /* --- Animations --- */
768
+ .step-card.enter {
769
+ animation: step-in 0.42s cubic-bezier(0.22, 1, 0.36, 1) both;
770
+ animation-delay: var(--enter-delay, 0ms);
771
+ }
772
+ @keyframes step-in {
773
+ from {
774
+ opacity: 0;
775
+ transform: translateY(10px) scale(0.98);
776
+ }
777
+ to {
778
+ opacity: 1;
779
+ transform: translateY(0) scale(1);
780
+ }
781
+ }
782
+ @keyframes dot-breathe {
783
+ 0%,
784
+ 100% {
785
+ transform: scale(1);
786
+ box-shadow: 0 0 0 0 rgba(245, 124, 0, 0.45);
787
+ }
788
+ 50% {
789
+ transform: scale(1.08);
790
+ box-shadow: 0 0 0 6px rgba(245, 124, 0, 0);
791
+ }
792
+ }
793
+ .step-card.just-done .step-rail .check {
794
+ animation: check-pop 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both;
795
+ }
796
+ @keyframes check-pop {
797
+ 0% {
798
+ transform: scale(0.4);
799
+ opacity: 0;
800
+ }
801
+ 60% {
802
+ transform: scale(1.18);
803
+ }
804
+ 100% {
805
+ transform: scale(1);
806
+ opacity: 1;
807
+ }
808
+ }
809
+ tbody tr.row-enter {
810
+ animation: row-in 0.34s cubic-bezier(0.22, 1, 0.36, 1) both;
811
+ animation-delay: var(--row-delay, 0ms);
812
+ }
813
+ @keyframes row-in {
814
+ from {
815
+ opacity: 0;
816
+ transform: translateY(6px);
817
+ }
818
+ to {
819
+ opacity: 1;
820
+ transform: translateY(0);
821
+ }
822
+ }
823
+ .summary .row--total span:last-child.bump {
824
+ animation: total-bump 0.45s ease;
825
+ display: inline-block;
826
+ }
827
+ @keyframes total-bump {
828
+ 0% {
829
+ transform: scale(1);
830
+ }
831
+ 35% {
832
+ transform: scale(1.12);
833
+ color: var(--primary);
834
+ }
835
+ 100% {
836
+ transform: scale(1);
837
+ }
838
+ }
839
+ /* A rate cell the apprentice just changed via chat: a brief highlight so the eye
840
+ lands on what moved (the table re-renders, so this draws attention to the new value). */
841
+ td.cell-pulse {
842
+ animation: cell-pulse 1s ease;
843
+ }
844
+ @keyframes cell-pulse {
845
+ 0% {
846
+ background: var(--primary-container);
847
+ box-shadow: inset 0 0 0 2px var(--primary);
848
+ }
849
+ 100% {
850
+ background: transparent;
851
+ box-shadow: inset 0 0 0 2px transparent;
852
+ }
853
+ }
854
+ @keyframes msg-in {
855
+ from {
856
+ opacity: 0;
857
+ transform: translateY(8px);
858
+ }
859
+ to {
860
+ opacity: 1;
861
+ transform: translateY(0);
862
+ }
863
+ }
864
+ @keyframes typing {
865
+ 0%,
866
+ 60%,
867
+ 100% {
868
+ transform: translateY(0);
869
+ opacity: 0.4;
870
+ }
871
+ 30% {
872
+ transform: translateY(-4px);
873
+ opacity: 1;
874
+ }
875
+ }
876
+
877
+ @media (prefers-reduced-motion: reduce) {
878
+ .step-card.enter,
879
+ .step-card.just-done .check,
880
+ tbody tr.row-enter,
881
+ .chat-msg,
882
+ .summary .row--total span.bump,
883
+ .step-rail .working-dot,
884
+ .typing .bubble i,
885
+ .pause-card,
886
+ .doc-item,
887
+ .live .live-dot,
888
+ td.cell-pulse,
889
+ .send-overlay,
890
+ .send-modal,
891
+ .send-confirm-icon,
892
+ #forge-btn:disabled .material-symbols-outlined {
893
+ animation: none !important;
894
+ }
895
+ }
896
+
897
+ /* --- Finalize & Send modal (S10) --- */
898
+ .send-overlay {
899
+ position: fixed;
900
+ inset: 0;
901
+ z-index: 50;
902
+ display: flex;
903
+ align-items: center;
904
+ justify-content: center;
905
+ background: rgba(24, 28, 30, 0.55);
906
+ backdrop-filter: blur(2px);
907
+ padding: 20px;
908
+ animation: send-fade 0.18s ease;
909
+ }
910
+ .send-overlay[hidden] {
911
+ display: none;
912
+ }
913
+ @keyframes send-fade {
914
+ from {
915
+ opacity: 0;
916
+ }
917
+ to {
918
+ opacity: 1;
919
+ }
920
+ }
921
+ .send-modal {
922
+ background: var(--surface-container-lowest);
923
+ border: 1px solid var(--outline-variant);
924
+ border-radius: 16px;
925
+ box-shadow: 0 12px 40px rgba(0, 0, 0, 0.28);
926
+ width: 100%;
927
+ max-width: 420px;
928
+ padding: 22px;
929
+ animation: send-rise 0.2s ease;
930
+ }
931
+ @keyframes send-rise {
932
+ from {
933
+ transform: translateY(8px);
934
+ opacity: 0;
935
+ }
936
+ to {
937
+ transform: translateY(0);
938
+ opacity: 1;
939
+ }
940
+ }
941
+ .send-head {
942
+ display: flex;
943
+ align-items: center;
944
+ justify-content: space-between;
945
+ margin-bottom: 16px;
946
+ }
947
+ .send-head h2 {
948
+ font-family: var(--font-head);
949
+ font-size: 19px;
950
+ margin: 0;
951
+ color: var(--on-surface);
952
+ }
953
+ .send-x {
954
+ background: none;
955
+ border: 0;
956
+ cursor: pointer;
957
+ color: var(--on-surface-variant);
958
+ display: flex;
959
+ padding: 4px;
960
+ border-radius: 8px;
961
+ }
962
+ .send-x:hover {
963
+ background: var(--surface-container);
964
+ }
965
+ .send-channels {
966
+ display: flex;
967
+ gap: 8px;
968
+ margin-bottom: 16px;
969
+ }
970
+ .send-chan {
971
+ flex: 1;
972
+ display: flex;
973
+ align-items: center;
974
+ justify-content: center;
975
+ gap: 6px;
976
+ padding: 10px;
977
+ border: 1px solid var(--outline-variant);
978
+ border-radius: 10px;
979
+ background: var(--surface-container-low);
980
+ color: var(--on-surface-variant);
981
+ font-family: var(--font-head);
982
+ font-size: 14px;
983
+ cursor: pointer;
984
+ }
985
+ .send-chan .material-symbols-outlined {
986
+ font-size: 18px;
987
+ }
988
+ .send-chan.is-active {
989
+ border-color: var(--primary);
990
+ background: var(--primary-fixed);
991
+ color: var(--on-primary-fixed-variant);
992
+ }
993
+ .send-label {
994
+ display: block;
995
+ font-size: 12px;
996
+ font-weight: 600;
997
+ text-transform: uppercase;
998
+ letter-spacing: 0.04em;
999
+ color: var(--on-surface-variant);
1000
+ margin-bottom: 6px;
1001
+ }
1002
+ .send-input {
1003
+ width: 100%;
1004
+ box-sizing: border-box;
1005
+ padding: 11px 12px;
1006
+ border: 1px solid var(--outline);
1007
+ border-radius: 10px;
1008
+ font-size: 15px;
1009
+ background: var(--surface-container-lowest);
1010
+ color: var(--on-surface);
1011
+ }
1012
+ .send-input:focus {
1013
+ outline: 2px solid var(--primary);
1014
+ outline-offset: 0;
1015
+ border-color: var(--primary);
1016
+ }
1017
+ .send-error {
1018
+ color: var(--error);
1019
+ font-size: 13px;
1020
+ margin: 8px 0 0;
1021
+ }
1022
+ .send-actions {
1023
+ display: flex;
1024
+ align-items: center;
1025
+ justify-content: flex-end;
1026
+ gap: 12px;
1027
+ margin-top: 18px;
1028
+ }
1029
+ .send-note {
1030
+ font-size: 12px;
1031
+ color: var(--on-surface-variant);
1032
+ margin: 12px 0 0;
1033
+ }
1034
+ #send-go.is-busy .material-symbols-outlined {
1035
+ animation: pulse 1.2s ease-in-out infinite;
1036
+ }
1037
+ /* Confirmation view */
1038
+ .send-confirm {
1039
+ text-align: center;
1040
+ padding: 8px 4px 4px;
1041
+ }
1042
+ .send-confirm-icon {
1043
+ font-size: 52px;
1044
+ }
1045
+ .send-confirm.is-sent .send-confirm-icon {
1046
+ color: #2e7d32;
1047
+ }
1048
+ .send-confirm.is-draft .send-confirm-icon {
1049
+ color: var(--primary-container);
1050
+ }
1051
+ .send-confirm h2 {
1052
+ font-family: var(--font-head);
1053
+ margin: 8px 0 4px;
1054
+ color: var(--on-surface);
1055
+ }
1056
+ .send-summary {
1057
+ font-family: var(--font-mono, monospace);
1058
+ font-size: 14px;
1059
+ color: var(--on-surface);
1060
+ margin: 4px 0 10px;
1061
+ }
1062
+ .send-confirm-sub {
1063
+ font-size: 13px;
1064
+ color: var(--on-surface-variant);
1065
+ margin: 0 0 16px;
1066
+ line-height: 1.5;
1067
+ }
1068
+ .send-ref {
1069
+ font-size: 11px;
1070
+ color: var(--on-surface-variant);
1071
+ margin: -8px 0 14px;
1072
+ }
1073
+
1074
+ /* Mic button — recording state pulses red so it's obvious it's live. */
1075
+ #mic-btn.recording {
1076
+ background: #ba1a1a;
1077
+ color: #fff;
1078
+ border-color: #ba1a1a;
1079
+ animation: mic-pulse 1.2s ease-in-out infinite;
1080
+ }
1081
+ @keyframes mic-pulse {
1082
+ 0%,
1083
+ 100% {
1084
+ box-shadow: 0 0 0 0 rgba(186, 26, 26, 0.5);
1085
+ }
1086
+ 50% {
1087
+ box-shadow: 0 0 0 6px rgba(186, 26, 26, 0);
1088
+ }
1089
+ }
1090
+
1091
+ /* Phone-capture pairing (Tier 3): QR + link inside the reused send-modal shell. */
1092
+ .phone-qr {
1093
+ display: flex;
1094
+ justify-content: center;
1095
+ padding: 12px 0;
1096
+ }
1097
+ .phone-qr svg {
1098
+ width: 200px;
1099
+ height: 200px;
1100
+ background: #fff;
1101
+ border-radius: 12px;
1102
+ padding: 10px;
1103
+ }
1104
+ .phone-link {
1105
+ text-align: center;
1106
+ font-size: 13px;
1107
+ color: var(--muted, #b3a796);
1108
+ word-break: break-all;
1109
+ }
1110
+ .phone-link a {
1111
+ color: var(--orange, #ff7a1a);
1112
+ }
1113
+
1114
+ /* Cold-start banner (HF Space scale-to-zero): waking → ready → fade. Honest feedback
1115
+ so a visitor on a cold load sees the app acknowledge itself, not a blank/hung page. */
1116
+ .boot-banner {
1117
+ display: flex;
1118
+ align-items: center;
1119
+ gap: 10px;
1120
+ margin: 0 0 12px;
1121
+ padding: 10px 14px;
1122
+ border-radius: 10px;
1123
+ font-size: 13px;
1124
+ background: #2c2620;
1125
+ border: 1px solid var(--line, #3a322a);
1126
+ color: var(--muted, #b3a796);
1127
+ transition: opacity 0.4s ease;
1128
+ }
1129
+ .boot-banner[data-state="ready"] {
1130
+ background: #14361f;
1131
+ border-color: #2e7d4f;
1132
+ color: #b8e6c8;
1133
+ }
1134
+ .boot-banner[data-state="slow"] {
1135
+ background: #3a2a14;
1136
+ border-color: #8a6a2a;
1137
+ color: #e6cfa0;
1138
+ }
1139
+ .boot-banner[hidden] {
1140
+ display: none;
1141
+ }
1142
+ .boot-spinner {
1143
+ width: 14px;
1144
+ height: 14px;
1145
+ border-radius: 50%;
1146
+ border: 2px solid var(--line, #3a322a);
1147
+ border-top-color: var(--orange, #ff7a1a);
1148
+ animation: boot-spin 0.8s linear infinite;
1149
+ flex: none;
1150
+ }
1151
+ .boot-banner[data-state="ready"] .boot-spinner,
1152
+ .boot-banner[data-state="slow"] .boot-spinner {
1153
+ display: none;
1154
+ }
1155
+ @keyframes boot-spin {
1156
+ to {
1157
+ transform: rotate(360deg);
1158
+ }
1159
+ }
1160
+ @media (prefers-reduced-motion: reduce) {
1161
+ .boot-spinner {
1162
+ animation: none;
1163
+ }
1164
+ }
quillwright/web/dashboard.html ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Dashboard | Quillwright</title>
7
+ <link
8
+ rel="stylesheet"
9
+ href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;700;800;900&family=Inter:wght@400;700&family=JetBrains+Mono:wght@500&display=swap"
10
+ />
11
+ <link
12
+ rel="stylesheet"
13
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0&display=swap"
14
+ />
15
+ <link rel="icon" type="image/png" sizes="32x32" href="/web/img/favicon-32.png" />
16
+ <link rel="icon" type="image/png" sizes="16x16" href="/web/img/favicon-16.png" />
17
+ <link rel="icon" href="/web/img/favicon.ico" sizes="any" />
18
+ <link rel="apple-touch-icon" href="/web/img/apple-touch-icon.png" />
19
+ <link rel="stylesheet" href="/web/css/theme.css" />
20
+ <link rel="stylesheet" href="/web/css/workspace.css" />
21
+ <link rel="stylesheet" href="/web/css/pages.css" />
22
+ </head>
23
+ <body>
24
+ <div class="app">
25
+ <aside class="sidebar">
26
+ <div class="brand">
27
+ <img class="brand-logo" src="/web/img/quillwright-logo-transparent.png" alt="" />
28
+ <div class="brand-text">
29
+ <h2>Quillwright</h2>
30
+ <p>Industrial Precision</p>
31
+ </div>
32
+ </div>
33
+ <nav>
34
+ <a class="nav-link" href="/">
35
+ <span class="material-symbols-outlined">description</span><span>Estimate Builder</span>
36
+ </a>
37
+ <a class="nav-link" href="/estimates">
38
+ <span class="material-symbols-outlined">folder</span><span>My Estimates</span>
39
+ </a>
40
+ <a class="nav-link active" href="/dashboard">
41
+ <span class="material-symbols-outlined">dashboard</span><span>Dashboard</span>
42
+ </a>
43
+ <a class="nav-link" href="/jobs">
44
+ <span class="material-symbols-outlined">work</span><span>Active Jobs</span>
45
+ </a>
46
+ <a class="nav-link" href="/inventory">
47
+ <span class="material-symbols-outlined">inventory_2</span><span>Parts Catalog</span>
48
+ </a>
49
+ </nav>
50
+ <a class="btn btn--primary new-estimate" href="/">New Estimate</a>
51
+ </aside>
52
+
53
+ <div class="content">
54
+ <div class="page-head">
55
+ <h1>System Overview</h1>
56
+ <p>Aggregated from your past jobs on this device. Sample pricing.</p>
57
+ </div>
58
+ <div class="page-body">
59
+ <div class="kpis" id="kpis"></div>
60
+
61
+ <div class="card">
62
+ <div class="card-head"><h2>Recent jobs</h2></div>
63
+ <div id="recent"></div>
64
+ </div>
65
+
66
+ <div class="card">
67
+ <div class="card-head"><h2>Your most common items</h2></div>
68
+ <div id="top-items" class="page-body" style="padding: 24px; gap: 8px"></div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ <script type="module" src="/web/js/dashboard.js"></script>
74
+ </body>
75
+ </html>
quillwright/web/estimates.html ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>My Estimates | Quillwright</title>
7
+ <link
8
+ rel="stylesheet"
9
+ href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;700;800;900&family=Inter:wght@400;700&family=JetBrains+Mono:wght@500&display=swap"
10
+ />
11
+ <link
12
+ rel="stylesheet"
13
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0&display=swap"
14
+ />
15
+ <link rel="icon" type="image/png" sizes="32x32" href="/web/img/favicon-32.png" />
16
+ <link rel="icon" type="image/png" sizes="16x16" href="/web/img/favicon-16.png" />
17
+ <link rel="icon" href="/web/img/favicon.ico" sizes="any" />
18
+ <link rel="apple-touch-icon" href="/web/img/apple-touch-icon.png" />
19
+ <link rel="stylesheet" href="/web/css/theme.css" />
20
+ <link rel="stylesheet" href="/web/css/workspace.css" />
21
+ <link rel="stylesheet" href="/web/css/pages.css" />
22
+ </head>
23
+ <body>
24
+ <div class="app">
25
+ <aside class="sidebar">
26
+ <div class="brand">
27
+ <img class="brand-logo" src="/web/img/quillwright-logo-transparent.png" alt="" />
28
+ <div class="brand-text">
29
+ <h2>Quillwright</h2>
30
+ <p>Industrial Precision</p>
31
+ </div>
32
+ </div>
33
+ <nav>
34
+ <a class="nav-link" href="/">
35
+ <span class="material-symbols-outlined">description</span><span>Estimate Builder</span>
36
+ </a>
37
+ <a class="nav-link active" href="/estimates">
38
+ <span class="material-symbols-outlined">folder</span><span>My Estimates</span>
39
+ </a>
40
+ <a class="nav-link" href="/dashboard">
41
+ <span class="material-symbols-outlined">dashboard</span><span>Dashboard</span>
42
+ </a>
43
+ <a class="nav-link" href="/jobs">
44
+ <span class="material-symbols-outlined">work</span><span>Active Jobs</span>
45
+ </a>
46
+ <a class="nav-link" href="/inventory">
47
+ <span class="material-symbols-outlined">inventory_2</span><span>Parts Catalog</span>
48
+ </a>
49
+ </nav>
50
+ <a class="btn btn--primary new-estimate" href="/">New Estimate</a>
51
+ </aside>
52
+
53
+ <div class="content">
54
+ <div class="page-head">
55
+ <h1>My Estimates</h1>
56
+ <p>
57
+ Every estimate you've saved, newest first. Reopen to edit, re-export, or resume the
58
+ chat.
59
+ </p>
60
+ </div>
61
+ <div class="page-body">
62
+ <div class="card">
63
+ <div class="card-head">
64
+ <h2>Saved</h2>
65
+ <span id="est-count" class="mono"></span>
66
+ </div>
67
+ <div id="estimates"></div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ </div>
72
+ <script type="module" src="/web/js/estimates.js"></script>
73
+ </body>
74
+ </html>
quillwright/web/index.html CHANGED
@@ -4,6 +4,10 @@
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>Forge Estimate | Quillwright</title>
 
 
 
 
7
  <link
8
  rel="stylesheet"
9
  href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;700;800;900&family=Inter:wght@400;700&family=JetBrains+Mono:wght@500&display=swap"
@@ -20,19 +24,49 @@
20
  <!-- Sidebar: only working destinations (current page + New Estimate). -->
21
  <aside class="sidebar">
22
  <div class="brand">
23
- <h2>Quillwright</h2>
24
- <p>Industrial Precision</p>
 
 
 
25
  </div>
26
  <nav>
27
  <a class="nav-link active" href="/">
28
  <span class="material-symbols-outlined">description</span>
29
  <span>Estimate Builder</span>
30
  </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  </nav>
32
  <button class="btn btn--primary new-estimate" id="new-estimate-btn">New Estimate</button>
33
  </aside>
34
 
35
  <div class="main">
 
 
 
 
 
 
 
 
 
 
 
36
  <!-- Top bar: job title only -->
37
  <header class="topbar">
38
  <h1>Forge Estimate: AC Unit Repair — 123 Maple St</h1>
@@ -41,10 +75,29 @@
41
  <!-- Capture input -->
42
  <div class="capture">
43
  <input id="transcript" value="replaced the capacitor and contactor, one hour labor" />
 
 
 
44
  <label class="btn" for="photo-input" title="Add job photos">
45
  <span class="material-symbols-outlined">add_a_photo</span>
46
  </label>
47
  <input id="photo-input" type="file" accept="image/*" multiple hidden />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  <button id="forge-btn" class="btn btn--primary">
49
  <span class="material-symbols-outlined">bolt</span> Forge estimate
50
  </button>
@@ -52,20 +105,43 @@
52
  <div id="thumbs" class="thumbs"></div>
53
 
54
  <div class="panes">
55
- <!-- Left: Digital Apprentice -->
 
56
  <section class="pane pane--log">
57
  <div class="pane-head">
58
  <div class="apprentice">
59
  <span class="material-symbols-outlined">auto_awesome</span>
60
  <h2>Digital Apprentice</h2>
 
 
 
 
61
  </div>
62
  <div class="live">
63
  <span id="forge-state">Idle</span><span class="live-dot"></span>
64
  </div>
65
  </div>
66
- <div class="pane-body" id="log">
67
- <p class="step-empty">Waiting for a job to forge…</p>
 
 
 
 
68
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  </section>
70
 
71
  <!-- Right: Draft Estimate -->
@@ -76,9 +152,15 @@
76
  <button class="btn" id="add-item-btn">
77
  <span class="material-symbols-outlined">add</span> Add Item
78
  </button>
 
 
 
79
  <button class="btn" id="pdf-btn">
80
  <span class="material-symbols-outlined">picture_as_pdf</span> Preview PDF
81
  </button>
 
 
 
82
  </div>
83
  </div>
84
  <div class="pane-body">
@@ -130,6 +212,78 @@
130
  </div>
131
  </div>
132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  <script type="module" src="/web/js/workspace.js"></script>
134
  </body>
135
  </html>
 
4
  <meta charset="utf-8" />
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
  <title>Forge Estimate | Quillwright</title>
7
+ <link rel="icon" type="image/png" sizes="32x32" href="/web/img/favicon-32.png" />
8
+ <link rel="icon" type="image/png" sizes="16x16" href="/web/img/favicon-16.png" />
9
+ <link rel="icon" href="/web/img/favicon.ico" sizes="any" />
10
+ <link rel="apple-touch-icon" href="/web/img/apple-touch-icon.png" />
11
  <link
12
  rel="stylesheet"
13
  href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;700;800;900&family=Inter:wght@400;700&family=JetBrains+Mono:wght@500&display=swap"
 
24
  <!-- Sidebar: only working destinations (current page + New Estimate). -->
25
  <aside class="sidebar">
26
  <div class="brand">
27
+ <img class="brand-logo" src="/web/img/quillwright-logo-transparent.png" alt="" />
28
+ <div class="brand-text">
29
+ <h2>Quillwright</h2>
30
+ <p>Industrial Precision</p>
31
+ </div>
32
  </div>
33
  <nav>
34
  <a class="nav-link active" href="/">
35
  <span class="material-symbols-outlined">description</span>
36
  <span>Estimate Builder</span>
37
  </a>
38
+ <a class="nav-link" href="/estimates">
39
+ <span class="material-symbols-outlined">folder</span>
40
+ <span>My Estimates</span>
41
+ </a>
42
+ <a class="nav-link" href="/dashboard">
43
+ <span class="material-symbols-outlined">dashboard</span>
44
+ <span>Dashboard</span>
45
+ </a>
46
+ <a class="nav-link" href="/jobs">
47
+ <span class="material-symbols-outlined">work</span>
48
+ <span>Active Jobs</span>
49
+ </a>
50
+ <a class="nav-link" href="/inventory">
51
+ <span class="material-symbols-outlined">inventory_2</span>
52
+ <span>Parts Catalog</span>
53
+ </a>
54
  </nav>
55
  <button class="btn btn--primary new-estimate" id="new-estimate-btn">New Estimate</button>
56
  </aside>
57
 
58
  <div class="main">
59
+ <!-- Cold-start banner: this Space scales to zero, so the first load after an idle
60
+ period boots the container. The banner confirms the app is waking, not broken,
61
+ then flips to "ready" and fades once /api/model_info responds. -->
62
+ <div class="boot-banner" id="boot-banner" data-state="waking" hidden>
63
+ <span class="boot-spinner" aria-hidden="true"></span>
64
+ <span id="boot-text"
65
+ >Waking the demo up — this Space sleeps when idle, so the first load takes ~30–60s. It's
66
+ not broken; hang tight.</span
67
+ >
68
+ </div>
69
+
70
  <!-- Top bar: job title only -->
71
  <header class="topbar">
72
  <h1>Forge Estimate: AC Unit Repair — 123 Maple St</h1>
 
75
  <!-- Capture input -->
76
  <div class="capture">
77
  <input id="transcript" value="replaced the capacitor and contactor, one hour labor" />
78
+ <button id="mic-btn" class="btn" title="Record a voice note (Cohere Transcribe)">
79
+ <span class="material-symbols-outlined">mic</span>
80
+ </button>
81
  <label class="btn" for="photo-input" title="Add job photos">
82
  <span class="material-symbols-outlined">add_a_photo</span>
83
  </label>
84
  <input id="photo-input" type="file" accept="image/*" multiple hidden />
85
+ <label
86
+ class="btn"
87
+ for="doc-input"
88
+ title="Add a document — supplier quote or spec sheet (Document Capture)"
89
+ >
90
+ <span class="material-symbols-outlined">document_scanner</span>
91
+ </label>
92
+ <input id="doc-input" type="file" accept="image/*" hidden />
93
+ <button
94
+ id="phone-btn"
95
+ class="btn"
96
+ type="button"
97
+ title="Capture from your phone (scan a QR)"
98
+ >
99
+ <span class="material-symbols-outlined">qr_code_2</span>
100
+ </button>
101
  <button id="forge-btn" class="btn btn--primary">
102
  <span class="material-symbols-outlined">bolt</span> Forge estimate
103
  </button>
 
105
  <div id="thumbs" class="thumbs"></div>
106
 
107
  <div class="panes">
108
+ <!-- Left: Digital Apprentice — one continuous stream (trace + chat),
109
+ with the chat input docked at the bottom of the pane. -->
110
  <section class="pane pane--log">
111
  <div class="pane-head">
112
  <div class="apprentice">
113
  <span class="material-symbols-outlined">auto_awesome</span>
114
  <h2>Digital Apprentice</h2>
115
+ <span id="model-badge" class="model-badge" title="Which models are running" hidden
116
+ ><span class="model-badge-dot"></span><span id="model-badge-mode">…</span
117
+ ><span id="model-badge-roles" class="model-badge-roles"></span
118
+ ></span>
119
  </div>
120
  <div class="live">
121
  <span id="forge-state">Idle</span><span class="live-dot"></span>
122
  </div>
123
  </div>
124
+
125
+ <!-- The stream: trace step cards, then chat turns, in one column. -->
126
+ <div class="stream" id="log">
127
+ <div class="steps">
128
+ <p class="step-empty">Waiting for a job to forge…</p>
129
+ </div>
130
  </div>
131
+
132
+ <!-- Docked chat input (disabled until an estimate exists). -->
133
+ <form class="chat-input" id="chat-form">
134
+ <input
135
+ id="chat-text"
136
+ type="text"
137
+ autocomplete="off"
138
+ disabled
139
+ placeholder="Forge an estimate, then refine it here…"
140
+ />
141
+ <button class="chat-send" id="chat-send" type="submit" disabled aria-label="Send">
142
+ <span class="material-symbols-outlined">arrow_upward</span>
143
+ </button>
144
+ </form>
145
  </section>
146
 
147
  <!-- Right: Draft Estimate -->
 
152
  <button class="btn" id="add-item-btn">
153
  <span class="material-symbols-outlined">add</span> Add Item
154
  </button>
155
+ <button class="btn" id="save-btn" title="Save this estimate to My Estimates">
156
+ <span class="material-symbols-outlined">save</span> Save
157
+ </button>
158
  <button class="btn" id="pdf-btn">
159
  <span class="material-symbols-outlined">picture_as_pdf</span> Preview PDF
160
  </button>
161
+ <button class="btn" id="json-btn" title="Export machine-readable JSON (no lock-in)">
162
+ <span class="material-symbols-outlined">data_object</span> Export JSON
163
+ </button>
164
  </div>
165
  </div>
166
  <div class="pane-body">
 
212
  </div>
213
  </div>
214
 
215
+ <!-- Finalize & Send modal (S10): pick a channel + recipient, then send -->
216
+ <div class="send-overlay" id="send-overlay" hidden>
217
+ <div
218
+ class="send-modal"
219
+ role="dialog"
220
+ aria-modal="true"
221
+ aria-labelledby="send-title"
222
+ id="send-modal"
223
+ >
224
+ <!-- Form view -->
225
+ <div id="send-form-view">
226
+ <header class="send-head">
227
+ <h2 id="send-title">Send estimate</h2>
228
+ <button class="send-x" id="send-cancel" type="button" aria-label="Close">
229
+ <span class="material-symbols-outlined">close</span>
230
+ </button>
231
+ </header>
232
+ <div class="send-channels" role="tablist" aria-label="Send via">
233
+ <button class="send-chan is-active" id="chan-sms" data-channel="sms" type="button">
234
+ <span class="material-symbols-outlined">sms</span> Text message
235
+ </button>
236
+ <button class="send-chan" id="chan-email" data-channel="email" type="button">
237
+ <span class="material-symbols-outlined">mail</span> Email
238
+ </button>
239
+ </div>
240
+ <label class="send-label" for="send-recipient" id="send-recipient-label">
241
+ Phone number
242
+ </label>
243
+ <input
244
+ class="send-input"
245
+ id="send-recipient"
246
+ type="text"
247
+ inputmode="tel"
248
+ placeholder="+1 555 123 4567"
249
+ autocomplete="off"
250
+ />
251
+ <p class="send-error" id="send-error" hidden></p>
252
+ <div class="send-actions">
253
+ <button class="link-btn" id="send-cancel-2" type="button">Cancel</button>
254
+ <button class="btn btn--primary" id="send-go" type="button">
255
+ Send <span class="material-symbols-outlined">send</span>
256
+ </button>
257
+ </div>
258
+ <p class="send-note" id="send-note"></p>
259
+ </div>
260
+
261
+ <!-- Confirmation view (populated by JS) -->
262
+ <div id="send-confirm-view" hidden></div>
263
+ </div>
264
+ </div>
265
+
266
+ <!-- Phone-capture pairing (Tier 3): scan the QR, capture on your phone -->
267
+ <div class="send-overlay" id="phone-overlay" hidden>
268
+ <div class="send-modal" role="dialog" aria-modal="true" aria-labelledby="phone-title">
269
+ <header class="send-head">
270
+ <h2 id="phone-title">Capture from your phone</h2>
271
+ <button class="send-x" id="phone-cancel" type="button" aria-label="Close">
272
+ <span class="material-symbols-outlined">close</span>
273
+ </button>
274
+ </header>
275
+ <p class="send-note">
276
+ Scan this with your phone camera, then take a photo and a voice note. It forges here, live
277
+ on this screen.
278
+ </p>
279
+ <div id="phone-qr" class="phone-qr"></div>
280
+ <p class="phone-link">
281
+ or open <a id="phone-url" href="#" target="_blank" rel="noopener"></a>
282
+ </p>
283
+ <p class="send-note" id="phone-status">Waiting for your phone…</p>
284
+ </div>
285
+ </div>
286
+
287
  <script type="module" src="/web/js/workspace.js"></script>
288
  </body>
289
  </html>
quillwright/web/inventory.html ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Parts Catalog | Quillwright</title>
7
+ <link
8
+ rel="stylesheet"
9
+ href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;700;800;900&family=Inter:wght@400;700&family=JetBrains+Mono:wght@500&display=swap"
10
+ />
11
+ <link
12
+ rel="stylesheet"
13
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0&display=swap"
14
+ />
15
+ <link rel="icon" type="image/png" sizes="32x32" href="/web/img/favicon-32.png" />
16
+ <link rel="icon" type="image/png" sizes="16x16" href="/web/img/favicon-16.png" />
17
+ <link rel="icon" href="/web/img/favicon.ico" sizes="any" />
18
+ <link rel="apple-touch-icon" href="/web/img/apple-touch-icon.png" />
19
+ <link rel="stylesheet" href="/web/css/theme.css" />
20
+ <link rel="stylesheet" href="/web/css/workspace.css" />
21
+ <link rel="stylesheet" href="/web/css/pages.css" />
22
+ </head>
23
+ <body>
24
+ <div class="app">
25
+ <aside class="sidebar">
26
+ <div class="brand">
27
+ <img class="brand-logo" src="/web/img/quillwright-logo-transparent.png" alt="" />
28
+ <div class="brand-text">
29
+ <h2>Quillwright</h2>
30
+ <p>Industrial Precision</p>
31
+ </div>
32
+ </div>
33
+ <nav>
34
+ <a class="nav-link" href="/">
35
+ <span class="material-symbols-outlined">description</span><span>Estimate Builder</span>
36
+ </a>
37
+ <a class="nav-link" href="/estimates">
38
+ <span class="material-symbols-outlined">folder</span><span>My Estimates</span>
39
+ </a>
40
+ <a class="nav-link" href="/dashboard">
41
+ <span class="material-symbols-outlined">dashboard</span><span>Dashboard</span>
42
+ </a>
43
+ <a class="nav-link" href="/jobs">
44
+ <span class="material-symbols-outlined">work</span><span>Active Jobs</span>
45
+ </a>
46
+ <a class="nav-link active" href="/inventory">
47
+ <span class="material-symbols-outlined">inventory_2</span><span>Parts Catalog</span>
48
+ </a>
49
+ </nav>
50
+ <a class="btn btn--primary new-estimate" href="/">New Estimate</a>
51
+ </aside>
52
+
53
+ <div class="content">
54
+ <div class="page-head">
55
+ <h1>Parts Catalog</h1>
56
+ <p>Read-only stock view. Prices mirror the estimate catalog. Sample data.</p>
57
+ </div>
58
+ <div class="page-body">
59
+ <div class="kpis" id="kpis"></div>
60
+
61
+ <div class="card">
62
+ <div class="card-head">
63
+ <h2>Inventory</h2>
64
+ <div class="filters" id="filters"></div>
65
+ </div>
66
+ <div id="parts"></div>
67
+ </div>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ <script type="module" src="/web/js/inventory.js"></script>
72
+ </body>
73
+ </html>
quillwright/web/jobs.html ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Active Jobs | Quillwright</title>
7
+ <link
8
+ rel="stylesheet"
9
+ href="https://fonts.googleapis.com/css2?family=Hanken+Grotesk:wght@400;700;800;900&family=Inter:wght@400;700&family=JetBrains+Mono:wght@500&display=swap"
10
+ />
11
+ <link
12
+ rel="stylesheet"
13
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0..1,0&display=swap"
14
+ />
15
+ <link rel="icon" type="image/png" sizes="32x32" href="/web/img/favicon-32.png" />
16
+ <link rel="icon" type="image/png" sizes="16x16" href="/web/img/favicon-16.png" />
17
+ <link rel="icon" href="/web/img/favicon.ico" sizes="any" />
18
+ <link rel="apple-touch-icon" href="/web/img/apple-touch-icon.png" />
19
+ <link rel="stylesheet" href="/web/css/theme.css" />
20
+ <link rel="stylesheet" href="/web/css/workspace.css" />
21
+ <link rel="stylesheet" href="/web/css/pages.css" />
22
+ </head>
23
+ <body>
24
+ <div class="app">
25
+ <aside class="sidebar">
26
+ <div class="brand">
27
+ <img class="brand-logo" src="/web/img/quillwright-logo-transparent.png" alt="" />
28
+ <div class="brand-text">
29
+ <h2>Quillwright</h2>
30
+ <p>Industrial Precision</p>
31
+ </div>
32
+ </div>
33
+ <nav>
34
+ <a class="nav-link" href="/">
35
+ <span class="material-symbols-outlined">description</span><span>Estimate Builder</span>
36
+ </a>
37
+ <a class="nav-link" href="/estimates">
38
+ <span class="material-symbols-outlined">folder</span><span>My Estimates</span>
39
+ </a>
40
+ <a class="nav-link" href="/dashboard">
41
+ <span class="material-symbols-outlined">dashboard</span><span>Dashboard</span>
42
+ </a>
43
+ <a class="nav-link active" href="/jobs">
44
+ <span class="material-symbols-outlined">work</span><span>Active Jobs</span>
45
+ </a>
46
+ <a class="nav-link" href="/inventory">
47
+ <span class="material-symbols-outlined">inventory_2</span><span>Parts Catalog</span>
48
+ </a>
49
+ </nav>
50
+ <a class="btn btn--primary new-estimate" href="/">New Estimate</a>
51
+ </aside>
52
+
53
+ <div class="content">
54
+ <div class="page-head">
55
+ <h1>Active Jobs</h1>
56
+ <p>Every estimate forged on this device, newest first.</p>
57
+ </div>
58
+ <div class="page-body">
59
+ <div class="card">
60
+ <div class="card-head">
61
+ <h2>Jobs</h2>
62
+ <span id="job-count" class="mono"></span>
63
+ </div>
64
+ <div id="jobs"></div>
65
+ </div>
66
+ </div>
67
+ </div>
68
+ </div>
69
+ <script type="module" src="/web/js/jobs.js"></script>
70
+ </body>
71
+ </html>
quillwright/web/js/client.js CHANGED
@@ -24,6 +24,12 @@ async function consumeStream(url, body, onEvent) {
24
  }
25
  }
26
 
 
 
 
 
 
 
27
  // Upload one image (base64 data URL); returns the server-side path.
28
  export async function uploadImage(dataUrl, filename) {
29
  const res = await fetch("/api/upload", {
@@ -59,6 +65,86 @@ export async function recalc(rows, jobTitle, taxRate) {
59
  return res.json();
60
  }
61
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  // Translate the customer-facing estimate copy into a language (Cohere Aya).
63
  export async function translateEstimate(rows, jobTitle, taxRate, language) {
64
  const res = await fetch("/api/translate", {
@@ -84,3 +170,36 @@ export async function downloadPdf(rows, jobTitle, taxRate) {
84
  a.click();
85
  URL.revokeObjectURL(url);
86
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  }
25
  }
26
 
27
+ // Which models fill each role right now (mode + per-role labels) for the badge.
28
+ export async function modelInfo() {
29
+ const res = await fetch("/api/model_info");
30
+ return res.json();
31
+ }
32
+
33
  // Upload one image (base64 data URL); returns the server-side path.
34
  export async function uploadImage(dataUrl, filename) {
35
  const res = await fetch("/api/upload", {
 
65
  return res.json();
66
  }
67
 
68
+ // Document Capture (ADR-0011): parse a handed-over document (supplier quote, spec
69
+ // sheet) into {model, observations, proposed_items} for the human to confirm.
70
+ export async function parseDocument(dataUrl, filename) {
71
+ const res = await fetch("/api/parse_document", {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({ data: dataUrl, filename }),
75
+ });
76
+ return res.json();
77
+ }
78
+
79
+ // Transcribe a recorded voice note (base64 data URL) into text (Cohere Transcribe).
80
+ export async function transcribeNote(dataUrl, filename) {
81
+ const res = await fetch("/api/transcribe", {
82
+ method: "POST",
83
+ headers: { "Content-Type": "application/json" },
84
+ body: JSON.stringify({ data: dataUrl, filename }),
85
+ });
86
+ const out = await res.json();
87
+ return out.transcript || "";
88
+ }
89
+
90
+ // Refine the current estimate conversationally (the Digital Apprentice chat).
91
+ // Carries the Refinement Thread (ADR-0013) in and back out. Returns
92
+ // {estimate, reply, needs_price, changed, thread}.
93
+ export async function chatAboutEstimate(message, rows, taxRate, thread, pending) {
94
+ const res = await fetch("/api/chat", {
95
+ method: "POST",
96
+ headers: { "Content-Type": "application/json" },
97
+ body: JSON.stringify({ message, rows, tax_rate: taxRate, thread, pending }),
98
+ });
99
+ return res.json();
100
+ }
101
+
102
+ // --- Saved Estimates (ADR-0013): per-account Estimate Store. ---
103
+
104
+ // Persist (create or update-in-place via `id`) a Saved Estimate + its thread.
105
+ // Returns {id}.
106
+ export async function saveEstimate(rows, jobTitle, taxRate, thread, id) {
107
+ const res = await fetch("/api/save_estimate", {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({ rows, job_title: jobTitle, tax_rate: taxRate, thread, id }),
111
+ });
112
+ return res.json();
113
+ }
114
+
115
+ // The account's saved estimates, newest first ({estimates: [{id, job_title, total}]}).
116
+ export async function listEstimates() {
117
+ const res = await fetch("/api/estimates");
118
+ return res.json();
119
+ }
120
+
121
+ // Reopen one saved estimate (frozen snapshot + thread), or null if missing.
122
+ export async function loadEstimate(id) {
123
+ const res = await fetch(`/api/estimate/${id}`);
124
+ if (!res.ok) return null;
125
+ return res.json();
126
+ }
127
+
128
+ // Discard a saved estimate.
129
+ export async function deleteEstimate(id) {
130
+ await fetch(`/api/estimate/${id}`, { method: "DELETE" });
131
+ }
132
+
133
+ // --- QR phone-capture pairing (Tier 3). ---
134
+
135
+ // Open a pairing for this desktop session. Returns {code, capture_url, qr_svg}.
136
+ export async function createPairing() {
137
+ const res = await fetch("/api/pair/create", { method: "POST" });
138
+ return res.json();
139
+ }
140
+
141
+ // Poll for the phone's capture (delivered once). Returns the capture or null.
142
+ export async function pollPairing(code) {
143
+ const res = await fetch(`/api/pair/${code}`);
144
+ const out = await res.json();
145
+ return out.capture;
146
+ }
147
+
148
  // Translate the customer-facing estimate copy into a language (Cohere Aya).
149
  export async function translateEstimate(rows, jobTitle, taxRate, language) {
150
  const res = await fetch("/api/translate", {
 
170
  a.click();
171
  URL.revokeObjectURL(url);
172
  }
173
+
174
+ // Finalize & Send (S10): deliver the estimate to a customer by SMS or email.
175
+ // Returns {status:"sent"|"drafted", transmitted, channel, recipient, summary, provider_id}.
176
+ // On bad input the server replies 400 with a plain-text reason.
177
+ export async function sendEstimate(channel, recipient, rows, jobTitle, taxRate) {
178
+ const res = await fetch("/api/send_estimate", {
179
+ method: "POST",
180
+ headers: { "Content-Type": "application/json" },
181
+ body: JSON.stringify({ channel, recipient, rows, job_title: jobTitle, tax_rate: taxRate }),
182
+ });
183
+ if (!res.ok) {
184
+ const reason = await res.text();
185
+ throw new Error(reason || "Send failed.");
186
+ }
187
+ return res.json();
188
+ }
189
+
190
+ // Download a machine-readable JSON of the current estimate (the "no lock-in" export).
191
+ export async function downloadJson(rows, jobTitle, taxRate) {
192
+ const res = await fetch("/api/export_json", {
193
+ method: "POST",
194
+ headers: { "Content-Type": "application/json" },
195
+ body: JSON.stringify({ rows, job_title: jobTitle, tax_rate: taxRate }),
196
+ });
197
+ const payload = await res.json();
198
+ const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
199
+ const url = URL.createObjectURL(blob);
200
+ const a = document.createElement("a");
201
+ a.href = url;
202
+ a.download = "estimate.json";
203
+ a.click();
204
+ URL.revokeObjectURL(url);
205
+ }
quillwright/web/js/dashboard.js ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Dashboard: render KPIs + recent jobs from the real memory read-model.
2
+ const money = (n) => (n == null ? "—" : `$${Number(n).toFixed(2)}`);
3
+ const el = (html) => {
4
+ const t = document.createElement("template");
5
+ t.innerHTML = html.trim();
6
+ return t.content.firstElementChild;
7
+ };
8
+
9
+ async function load() {
10
+ const data = await (await fetch("/api/dashboard")).json();
11
+
12
+ document.getElementById("kpis").append(
13
+ el(`<div class="kpi"><p class="label">Jobs forged</p><p class="value">${data.job_count}</p>
14
+ <p class="sub">on this device</p></div>`),
15
+ el(`<div class="kpi kpi--dark"><p class="label">Total estimated</p>
16
+ <p class="value">${money(data.revenue_total)}</p>
17
+ <p class="sub">sum of finished estimates</p></div>`),
18
+ el(`<div class="kpi"><p class="label">Distinct items used</p>
19
+ <p class="value">${data.top_items.length}</p>
20
+ <p class="sub">across past jobs</p></div>`),
21
+ );
22
+
23
+ const recent = document.getElementById("recent");
24
+ if (!data.recent.length) {
25
+ recent.append(
26
+ el(`<div class="empty"><span class="material-symbols-outlined">description</span>
27
+ No jobs yet. <a href="/">Forge your first estimate →</a></div>`),
28
+ );
29
+ } else {
30
+ const rows = data.recent
31
+ .map(
32
+ (r) => `<tr>
33
+ <td class="desc">${r.transcript || "(no note)"}</td>
34
+ <td>${r.items}</td>
35
+ <td class="num">${money(r.total)}</td>
36
+ </tr>`,
37
+ )
38
+ .join("");
39
+ recent.append(
40
+ el(`<table><thead><tr><th>Job note</th><th>Items</th><th class="num">Total</th></tr></thead>
41
+ <tbody>${rows}</tbody></table>`),
42
+ );
43
+ }
44
+
45
+ const top = document.getElementById("top-items");
46
+ if (!data.top_items.length) {
47
+ top.append(el(`<p class="step-empty">No items learned yet.</p>`));
48
+ } else {
49
+ top.append(
50
+ el(
51
+ `<div class="filters">${data.top_items
52
+ .map((i) => `<span class="chip chip--cat">${i}</span>`)
53
+ .join("")}</div>`,
54
+ ),
55
+ );
56
+ }
57
+ }
58
+
59
+ load();