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 filesBrings 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
- CONTEXT.md +35 -1
- Dockerfile +1 -1
- README.md +138 -2
- data/recall_evalset.json +133 -0
- data/sample_inventory.json +69 -0
- package-lock.json +2 -2
- package.json +7 -1
- pyproject.toml +25 -0
- quillwright/agent.py +25 -6
- quillwright/api/chat.py +551 -0
- quillwright/api/document.py +69 -0
- quillwright/api/estimate.py +64 -8
- quillwright/api/export.py +18 -0
- quillwright/api/pages.py +94 -0
- quillwright/api/pdf_links.py +40 -0
- quillwright/api/qr.py +24 -0
- quillwright/api/recalc.py +11 -1
- quillwright/api/send.py +285 -0
- quillwright/api/tools_api.py +151 -0
- quillwright/api/transcribe.py +35 -0
- quillwright/api/voice.py +429 -0
- quillwright/backends/audio.py +92 -0
- quillwright/backends/embedding.py +30 -0
- quillwright/backends/modal.py +202 -0
- quillwright/backends/modal_app.py +103 -0
- quillwright/backends/modal_aya_app.py +73 -0
- quillwright/backends/modal_omni_app.py +101 -0
- quillwright/backends/modal_parse_app.py +139 -0
- quillwright/backends/parse.py +156 -0
- quillwright/brain_loop.py +7 -2
- quillwright/estimate_store.py +91 -0
- quillwright/memory.py +42 -4
- quillwright/models.py +22 -1
- quillwright/pairing.py +63 -0
- quillwright/recall_eval.py +98 -0
- quillwright/resolver.py +141 -5
- quillwright/server.py +438 -9
- quillwright/theme.css +90 -17
- quillwright/thread.py +33 -0
- quillwright/tools.py +38 -4
- quillwright/web/css/pages.css +217 -0
- quillwright/web/css/theme.css +2 -0
- quillwright/web/css/workspace.css +814 -69
- quillwright/web/dashboard.html +75 -0
- quillwright/web/estimates.html +74 -0
- quillwright/web/index.html +159 -5
- quillwright/web/inventory.html +73 -0
- quillwright/web/jobs.html +71 -0
- quillwright/web/js/client.js +119 -0
- 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 |
-
#
|
| 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 .
|
| 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": "
|
| 3 |
"lockfileVersion": 3,
|
| 4 |
"requires": true,
|
| 5 |
"packages": {
|
| 6 |
"": {
|
| 7 |
-
"name": "
|
| 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 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 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 |
-
"""
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
return _stub_perception(transcript)
|
| 67 |
|
| 68 |
|
| 69 |
def _brain():
|
| 70 |
-
"""Real
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
return None
|
| 74 |
|
| 75 |
|
|
@@ -116,10 +156,22 @@ def forge_estimate(
|
|
| 116 |
{"configurable": {"thread_id": "ui"}},
|
| 117 |
)
|
| 118 |
est = out.get("estimate")
|
| 119 |
-
|
| 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":
|
| 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="
|
| 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(
|
|
|
|
|
|
|
| 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=
|
| 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(
|
| 24 |
-
self
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 38 |
-
"audio": "
|
| 39 |
-
"brain": "
|
| 40 |
}
|
| 41 |
BEST_STACK = {
|
| 42 |
"perception": "Nemotron-3-Nano-Omni",
|
| 43 |
"audio": "Nemotron-3-Nano-Omni",
|
| 44 |
-
"brain": "
|
| 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
|
| 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
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
/*
|
| 2 |
-
@import url(
|
| 3 |
-
:root {
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
.ff-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
.ff-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 65 |
-
|
| 66 |
-
except json.JSONDecodeError:
|
| 67 |
return []
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
}
|
| 158 |
.apprentice .material-symbols-outlined {
|
| 159 |
color: var(--primary);
|
| 160 |
animation: pulse 2s infinite;
|
| 161 |
}
|
| 162 |
-
|
| 163 |
-
|
|
|
|
|
|
|
| 164 |
align-items: center;
|
| 165 |
-
gap:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
color: var(--secondary);
|
| 167 |
-
|
| 168 |
}
|
| 169 |
-
.
|
| 170 |
-
width:
|
| 171 |
-
height:
|
| 172 |
border-radius: 50%;
|
| 173 |
-
background:
|
| 174 |
-
|
| 175 |
-
}
|
| 176 |
-
|
| 177 |
-
/* Apprentice step cards. */
|
| 178 |
-
.steps {
|
| 179 |
-
display: flex;
|
| 180 |
-
flex-direction: column;
|
| 181 |
-
gap: 16px;
|
| 182 |
}
|
| 183 |
-
.
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
border-radius: 12px;
|
| 189 |
-
padding: 16px;
|
| 190 |
-
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
| 191 |
}
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
|
|
|
| 195 |
}
|
| 196 |
-
.
|
| 197 |
-
|
| 198 |
-
flex-direction: column;
|
| 199 |
-
align-items: center;
|
| 200 |
}
|
| 201 |
-
.
|
| 202 |
-
|
| 203 |
-
font-variation-settings: "FILL" 1;
|
| 204 |
}
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
height: 24px;
|
| 208 |
-
border-radius: 50%;
|
| 209 |
-
background: var(--primary-container);
|
| 210 |
display: flex;
|
| 211 |
align-items: center;
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
| 213 |
}
|
| 214 |
-
|
| 215 |
-
|
|
|
|
| 216 |
width: 8px;
|
| 217 |
height: 8px;
|
| 218 |
border-radius: 50%;
|
| 219 |
-
background:
|
|
|
|
|
|
|
|
|
|
| 220 |
}
|
| 221 |
-
.
|
| 222 |
-
|
| 223 |
-
|
| 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 |
-
.
|
| 241 |
-
|
|
|
|
| 242 |
}
|
| 243 |
-
.
|
| 244 |
-
|
| 245 |
-
|
| 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 |
-
<
|
| 24 |
-
<
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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();
|