Skip to content

Repository files navigation

Quote-Battle Agent

Facilities-maintenance quoting autopilot: it doesn't just price the job — it rehearses the negotiation.

Every quoting tool tells you what you should charge. This one also works out what the client is willing to pay, writes a justification they can say yes to, and then war-games how they'll push back — before you ever walk into the room.

Thesis: AI preps the fight. You close it. The agent runs the rehearsal; a human makes every decision that matters. The two approval gates aren't a UX afterthought — they're the architecture.

I'm not a developer who picked a domain. I run a facilities-maintenance company and quote these jobs myself. I've gone through thousands of maintenance invoices by hand and watched a six-figure profit leak hide in quotes priced a little too low, a little too fast. The hard part of quoting was never the arithmetic — it's pricing the haggle under thin margins, where one underpriced recurring contract compounds into a loss. So I built the quoting agent I actually needed. (All data in this repo is 100% synthetic — fictional clients, fictional prices.)


What it does

① Work order comes in            (e.g. walk-in refrigerator down, store losing cold chain)
② Two-anchor pricing             vertical = market going-rate (the floor)
                                 horizontal = THIS client's willingness to pay,
                                 inferred from their own deal history (the ceiling)
   + a client-facing justification written for this exact job
   🔶 GATE 1 — human approves / adjusts price / rewrites pitch
③ Battle rehearsal (self-play)   the agent ARGUES BOTH SIDES: a client agent
                                 attacks the price, a contractor agent defends it,
                                 turn by turn on screen — then the bout is distilled
                                 into the 2-3 strongest attacks and your counters
   🔶 GATE 2 — human keeps the lines worth keeping
④ Output                         a defensible quote + a negotiation battle card
⑤ The loop                       deal won/lost (+ why) writes BACK to the client's
                                 history → the next quote prices against it
⑥ The playbook                   every line you keep is distilled into a reusable
                                 negotiation principle — cross-deal craft, not per-deal state
                                 → and briefed into the NEXT rehearsal, which cites
                                 the moves it used (📖 "From your playbook")

Persistent memory — the loop, visibly working

Outcomes aren't logged into a void. A recorded loss ("exceeded approved budget") becomes a history row that the very next quote for that client cites — you can watch the deal counter climb and the pricing rationale change. The agent gets sharper with every deal, per client.

Frozen quotes — a production decision

LLM temperature means the "same" quote can drift between runs. In a real business that's intolerable: a client who sees two different prices stops trusting both, and internal records stop being auditable. So every generated quote is frozen to the database — reopening a work order returns the exact same quote, instantly. A new roll requires an explicit Regenerate. One mechanism, four wins: consistency, auditability, token savings, and no 18-second wait on reopen.

This isn't asserted — it's measured. npm run eval:pricing replays the product's own prompt 3× per work order against the live model (docs/eval-pricing.md): 24/24 valid JSON, 24/24 prices inside the two anchors (the anchors drive the price — they're not decoration), but run-to-run price spread up to 18.8% on the same order. Consistent in placement, not in digits — which is exactly why the digits get frozen.

The same rule covers the human's decisions: the approved price and pitch and the battle rehearsal persist with the order. Reopen a work order and everything is sitting where you left it — the rehearsal is an artifact, not a session effect.

The engineering tradeoff — consistency as a state machine, not a frozen cell:

  • The freeze is a chain, not a table. Three snapshots, each keyed tighter — the quote by work order, the rehearsal by work order + price, each kept play by + scenario. The rehearsal is content-addressed on the price it argued for: reopen at a price already seen and the cached bout returns instantly.
  • Regenerate cascades — on purpose. A fresh quote DELETEs the battle and clears approval + outcome, because a rehearsal defending a price that no longer exists is worse than none. The rejected alternative — leaving stale artifacts lying around — is the one that silently rots an audit trail.
  • Truth is kept out of the model. The "past N deals, M won, why" line that proves the loop feeds pricing is computed from DB rows, never generated; outcome write-back enforces three invariants (an approved quote must exist, no double-recording, a loss must carry its reason — the learning signal). The LLM is removed exactly where a wrong number would be a lie.

Streamed, not spinner'd

A fresh quote doesn't hide behind a loading line — it assembles itself on screen. The API routes stream the model's tokens over SSE, and the client parses the incomplete JSON on every delta (lib/partial-json.ts): the recommended price lands first (~2s), the two anchor cards pop in as they complete, then the justification types itself out. Numbers are held until they're settled — you never see $48 on its way to $4,800.

Two latency findings behind this (measured on this workload, scripts/probe-thinking.mjs):

first token full quote
before: blocking call, default settings ~20.5s white screen
streaming alone (thinking still on) 19.1s 20.5s — thinking is silent, so streaming bought almost nothing
streaming + thinking off for interactive calls ~1.2s ~3.6s

Hybrid Qwen models think before they speak by default — 19s of a 20.5s answer was invisible reasoning. So thinking is disabled where a human is watching (quote, battle) and kept where latency is free (playbook distillation runs after the optimistic UI, so it spends its thinking budget on a better principle instead). Reopening a quoted order still skips the model entirely — frozen snapshots stay instant.

Structured outputs ride native JSON mode (response_format: json_object) — probed to compose with streaming and thinking-off on this endpoint (scripts/probe-jsonmode.mjs), so the quote, the battle-card summary, and the distilled principles are guaranteed-valid JSON instead of fence-stripping prayers. Self-play turns stay plain prose — they're spoken lines, not data.

The playbook — teach the craft, not just the deal

Negotiation moves repeat across deals even when the words don't. So the lines you keep at gate 2 don't stay buried in one work order: each kept exchange is stored verbatim and distilled by the model into a one-line reusable principle ("Reframe a budget cap as cost-of-delay; concede on terms, never on rate"). They accumulate into a cross-deal playbook — the same memory system that learns your clients also grows you. Plays survive rehearsal regeneration on purpose: they're knowledge, not state. If distillation fails, the play is kept without the principle — never lost.

And the playbook feeds back. Every new rehearsal is briefed with your kept principles, and each counter cites the moves it actually used — visible on the battle card as 📖 "From your playbook". That closes the second memory loop: outcomes → client memory → sharper pricing; kept plays → playbook → sharper rehearsals. The model is told to never force a citation — a principle is applied only where it genuinely strengthens the counter. (At today's scale the whole playbook fits in the prompt; at real scale this brief becomes embedding retrieval of the most relevant plays — same loop, bigger library.)

Self-play — the rehearsal earns its card

One prompt asked to "imagine the haggle" produces plausible objections. A bout produces tested ones: a client agent (in character, armed with this client's real deal history) attacks the price; a contractor agent (briefed with your playbook) defends it — two exchanges, every turn streaming on screen. Only then does a third pass distill the transcript into the battle card, keeping the attack lines the client agent actually used and the counters that actually held. Each attack is labeled with the negotiation move behind it (anchor on a past deal, budget-cap squeeze, competitor leverage) — because moves repeat across deals even when the words change, which is the same observation the playbook is built on. In a measured run the client agent opened by quoting the client's own prior deal ("you did our last electrical job for $2,790 — why is this $160 more?") — that specificity is what an adversarial pass buys. The full bout is stored with the card: reopen the order and "View the self-play bout behind this card" replays the argument that produced your advice (~11s end-to-end, every token visible).

The engineering tradeoff — a creative process behind a deterministic interface:

  • Sequential by necessity. Five model calls in order (client → contractor, ×2, then a distiller), each turn conditioned on the transcript so far — a dialogue can't be parallelized. The ~11s is paid on purpose: objections that survived a real exchange beat ones a single prompt imagined.
  • A free-form bout, forced back into a fixed contract. The snapshot, the gate-2 keep, the 📖 citations, and the card UI were all built against a scenarios JSON shape before self-play existed. So the bout runs hot (temperature 0.7, sound alive) and a final pass distills it back into that exact shape (temperature 0.4, native JSON mode) — adding adversarial multi-agent rehearsal didn't touch a single downstream consumer.
  • Citations validated server-side. sanitizeRefs lets a counter cite only a principle that was actually in its brief — "📖 From your playbook" can't be a hallucinated reference.

Architecture

flowchart LR
  subgraph Browser
    UI["Next.js UI<br/>work orders · gates · battle card"]
  end
  subgraph "Next.js API routes (the agent)"
    Q["/api/agent/quote<br/>two-anchor pricing"]
    B["/api/agent/battle<br/>haggle rehearsal"]
    A["/api/approve<br/>gate-1 writeback"]
    O["/api/outcome<br/>win/loss writeback"]
    P["/api/playbook<br/>keep + distill principle"]
  end
  LLM["Qwen Cloud (OpenAI-compatible)<br/>interactive: qwen3.6-flash, thinking off<br/>background: qwen3.7-plus, thinking on"]
  DB[("PostgreSQL<br/>PolarDB · Alibaba Cloud<br/>history · benchmarks ·<br/>frozen snapshots · playbook")]

  UI -->|"① quote (SSE stream)"| Q
  UI -->|"🔶 gate 1"| A
  UI -->|"③ rehearse (self-play ×2, SSE)"| B
  UI -->|"🔶 gate 2: keep"| P
  UI -->|"⑤ outcome"| O
  Q --> LLM
  B --> LLM
  P -->|"distill the move"| LLM
  Q <--> DB
  B <--> DB
  A --> DB
  P <--> DB
  O -->|"learning loop"| DB
  DB -.->|"playbook briefs the rehearsal"| B
Loading
  • No agent framework. The flow is staged API calls with a human gate between stages — that round-trip is the product's thesis in code form. A graph framework would bury the most important edges (the human ones) inside a library.
  • Data access layer as a seam. Slices 1-3 ran on in-memory synthetic data; slice 4 swapped the accessors to PostgreSQL without touching a single API route or UI component.
  • Docker Postgres in dev; PolarDB for PostgreSQL on Alibaba Cloud for the deployed build — same connection string mechanism, one env line, zero code change.

Model selection (measured, not vibes)

Model Latency (this workload) Output quality
qwen3.7-plus ~37.5s good — occasionally more precise numbers
qwen3.6-flash ~18s (2× faster) good — more specific justifications in our A/B

Both clear the quality bar, so the pick is latency × cost: flash. The lesson isn't "use the biggest model" — it's match the model to the job and measure. Switching models is one line of .env.

That lesson generalized into routing by task profile (lib/llm.ts):

Profile Who's waiting Model Thinking Why
interactive — quote, battle a human, watching tokens stream qwen3.6-flash off first token ~1.2s; speed is the quality
background — playbook distillation nobody (optimistic UI already confirmed the keep) qwen3.7-plus on latency is free — spend it on a better principle

One .env line per profile (LLM_MODEL_INTERACTIVE / LLM_MODEL_BACKGROUND); both fall back to LLM_MODEL.

The engineering tradeoff — routing is a policy, and it stays portable:

  • Portable by feature-detection. The thinking toggle (enable_thinking) is DashScope-specific, so it's gated by base URL — the same codebase optimizes hard on Qwen and still runs unchanged on any OpenAI-compatible provider (OpenAI, Groq, DeepSeek).
  • Three dials, per task profile. Every call is routed on model (flash vs plus) × thinking (off vs on) × temperature (0.4 quote / 0.7 bout / 0.3 distill) — fast-and-cold where a human waits on tokens, slow-and-warm where nobody does.
  • Degrades to one line. Both profiles fall back to LLM_MODEL, so a single-model .env runs the whole app unchanged — the routing is an optimization, never a requirement.

Failure modes & error handling

Production-readiness is mostly about what happens when things go sideways:

Failure Handling
A price streams in mid-digit Settled-gate: a number renders only once its value is complete in the JSON
Truncated / malformed streaming JSON Incremental tolerant parser (lib/partial-json.ts), fuzz-tested against 1,168 truncation points; structured calls additionally ride native JSON mode
Playbook distillation fails The kept line is written first (INSERT-first, durable in ~0.5s); distillation enriches it in the background, and on failure the play survives verbatim — knowledge is never lost to a model error
Distillation is slow (~16-23s) Optimistic UI: the keep is confirmed instantly, a ⏳ placeholder backfills by light polling — nobody waits on a background model call
Double-click on Keep In-flight dedup + idempotent upsert — measured: exactly one distillation per keep
Reopening a quoted order Never re-rolls: frozen snapshot, instant and identical (measured drift without it: up to 18.8% on the same order)
No genuine playbook match The brief instructs: never force a citation — a principle is cited only where it actually strengthens the counter
Model or endpoint needs swapping Per-profile env with fallback (LLM_MODEL_INTERACTIVE / LLM_MODEL_BACKGROUNDLLM_MODEL)

What we don't measure — deliberately. eval:pricing covers what's measurable before field use: output validity, anchor adherence, run-to-run drift. It does not score justification quality or battle-card usefulness, because negotiations are adversarial and shifting — a usefulness benchmark of our own invention would be theater. The honest instrument is built into the product instead: every outcome writes back (won, or lost and why), so the system accumulates its own ground truth deal by deal in production.

Run it locally

# 1. deps
npm install

# 2. database (one-time): local Postgres in docker + schema + synthetic seed
docker run -d --name quote-battle-pg \
  -e POSTGRES_PASSWORD=localdev -e POSTGRES_DB=quote_battle \
  -p 5433:5432 -v quote-battle-pgdata:/var/lib/postgresql/data postgres:16-alpine
cp .env.example .env        # then paste your Qwen Cloud API key
npm run db:init

# 3. go
npm run dev                 # http://localhost:3000

Get a Qwen Cloud key at home.qwencloud.com → API Keys. Any OpenAI-compatible provider works (swap three LLM_* lines in .env), but the product targets Qwen.

Synthetic data, on purpose

Every client, price, deal, and work order in this repo is fictional (see db/init.sql). The problem is real — the data never was. No real client names, contracts, or pricing exist anywhere in this codebase or its history.

One order is seeded vague on purpose: WO-1008 arrives as a secondhand complaint — "keeps backing up every few weeks, smells bad some mornings, plunger doesn't help" — intermittent, cause unknown. Real work orders are half-articulated like this more often than not. Quote it and watch the agent price the ambiguity instead of choking on it: a diagnostic-first quote, framed around this client's known sensitivity (they once walked over speed).

Hackathon

Built for the Qwen Cloud Global AI Hackathon — Track 4: Autopilot Agent. The track asks for agents that automate real-world business workflows end-to-end, handle ambiguous inputs, and "incorporate human-in-the-loop checkpoints at critical decision points," with an emphasis on production-readiness over toy demos. Mapped onto this build: a free-text fault description goes in (the ambiguous artifact every real work order starts as), a priced, defensible, rehearsed position comes out (end-to-end), and the two approval gates are those checkpoints — placed exactly where money changes hands. Frozen quotes, measured drift, the failure-mode handling above, and outcome write-back are the production-readiness part.

Product brain runs on Qwen (qwen3.6-flash + qwen3.7-plus via the OpenAI-compatible endpoint); development was pair-programmed with AI coding tools, with every architectural call made by a human — which is rather the point of the product, too.

Deployment. The backend was deployed and verified end-to-end on Alibaba CloudFunction Compute (custom Node.js runtime, us-west-1) serving the Next.js app, PolarDB for PostgreSQL for the data, Qwen via the DashScope endpoint for the model. A separate deployment-proof recording (linked in the Devpost submission) shows the live backend on Alibaba Cloud serving real requests — work orders read from PolarDB, a fresh quote generated by Qwen, and the X-Fc-Request-Id gateway header on every response. To stay within the hackathon's free tier the hosted instance isn't kept permanently online; run it locally with the steps above, or follow docs/DEPLOY-aliyun.md to redeploy.

License

MIT — see LICENSE.

About

Facilities-maintenance quoting autopilot: two-anchor pricing, human approval gates, haggle rehearsal, and a self-growing negotiation playbook. Qwen Cloud Global AI Hackathon — Autopilot track.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages