---
name: evolution-ai-quote-notes
description: "AI Quote Notes feature on quotedit.inc — Claude-drafted scope-of-work notes, generate/preview/deploy per section or main"
metadata: 
  node_type: memory
  type: project
  originSessionId: 08b1305b-a52e-49c0-ac3e-d0e321623440
  modified: 2026-07-27T04:57:10.063Z
---

Shipped 2026-07-23: an "AI Notes" icon (`auto_awesome`) on `quotedit.inc`'s toolbar opens a modal
(`#aiNotesModal`) that lets a user generate a client-facing scope-of-work summary from a quote's
line items — either the whole quote ("Main Notes") or one section at a time — preview it, then
Deploy to overwrite-and-immediately-save.

**Architecture:** new `library/claudeQuoteNotes.php` (modeled on `library/claudeSearch.php`'s
curl/Claude-API pattern) + one new AJAX block (`call=aiGenerateQuoteNotes`) in
`quoteditsave.php`. Only `description/qty/uom` are sent to Claude — no prices/costs. Deploy does
**not** introduce a new save endpoint — it reuses the existing `saveNotes()` (main scope) and
`sectionEdit()` (section scope) JS functions, so persistence goes through already-tested paths.

**Gotcha hit while testing (not a bug):** `sectionEdit()` has a client-side lock guard
(`if(document.getElementById("locked").value=="1"){ alert(...); return; }`) that silently no-ops
— no network call, no thrown error — when the quote is locked. Dev quote id=1 is locked, which
made Deploy look broken (no `call=sectionEdit` request ever fired) until traced with a
Playwright debug script probing `document.getElementById('locked').value` directly. **For any
future quotedit.inc testing, use an unlocked quote** (query `select id from quotes where
locked=0` on the dev tenant) — locked-quote symptoms look identical to a real persistence bug.

Docs page: `docs/sales/ai-quote-notes.php` (nav: Sales section). Prod release note id 141,
`status=0` (unapproved, awaiting Shane's approval + docs git deploy) per
[[live_inject_release_notes]].

---

**Phase 2a — fidelity modes (built 2026-07-27, MIGRATION NOT YET RUN).** Original complaint: a
template's polished preamble was discarded and only line-item dot points survived. Four causes:
template was `strip_tags()`'d to plain text, the prompt explicitly *forbade* reusing its wording,
the system prompt mandated a tight bullet summary, and `<h3>/<h4>` weren't whitelisted. Fifth gap:
no client/site/quote context was ever sent, so "adapt to this client" was impossible.

Mode ladder `items | style | adapt | strict` (`claudeQuoteNotes::MODES`), default **adapt**.
`MODE_MODEL`: items/style → haiku-4-5, adapt/strict → `claude-sonnet-5` (approved by Shane).
`style` = the old behaviour. No template selected ⇒ forced to `items`. Auto-Pricing Table was
explicitly deferred out of scope; `<table>` is NOT in `ALLOWED_TAGS`, so template tables (incl.
spec tables like dev template id=1) get reworded to prose — documented, not a bug.

Merge tokens (`@company;` etc.) are resolved **server-side** in `claudeQuoteNotes` before the
template reaches Claude — `quotedit.inc` only resolves them client-side and `print.php` only
re-substitutes `@company;`.

Files: `library/claudeQuoteNotes.php` (rewritten), `quoteditsave.php` (`mode` +
`userinstructions`), `quotedit.inc` + `assets/js/modules/quotedit.js` (mode select, hint,
Extra-instructions box, `[placeholder]` warnings), `messageedit.inc`/`messagenew.inc`/
`messageTemplateSave.php` (per-template `aiMode`/`aiInstructions` defaults).

**Two landmines:** (1) TinyMCE in messageedit/messagenew used `selector: 'textarea'` — narrowed to
`textarea#textarea` or the new plain AI box becomes a rich editor. (2) With
`PDO::ERRMODE_EXCEPTION`, selecting the new columns on an un-migrated tenant *throws* and kills AI
Notes entirely — hence `hasAiColumns()` graceful degradation. Migration
`migrations/messagetemplates_ai_instructions.sql` must be run **per tenant**.

**Live-verified 2026-07-27** once Shane supplied a real key. Three bugs only the live run exposed:

1. **`content[0]["text"]` returns nothing on Sonnet 5.** The Messages API `content` is an array of
   blocks and reasoning models emit a `thinking` block FIRST, so index 0 has no text — adapt and
   strict silently produced empty drafts. Fixed with `extractText()`, which concatenates every
   `type === "text"` block. **`library/claudeSearch.php:94` still has the identical bug** — latent
   only because it is pinned to haiku; it breaks the moment that model is changed.
2. **Haiku cannot hold a template at arm's length.** Style mode ("borrow the tone, not the words")
   was reproducing templates near-verbatim no matter how emphatic the prompt. Sonnet obeys the
   same prompt perfectly, so it is a model-capability limit, not a prompt bug — `style` was moved
   to sonnet. `items` stays on haiku (no template in context, output verified correct).
3. **max_tokens truncation was silent.** Real quotes hit the 4000 ceiling and returned a document
   stopping mid-sentence that still looked finished. Raised adapt/strict to 8000 AND added a
   `stop_reason === "max_tokens"` check surfaced as a "Draft was cut short" warning.

Cost signal from `claudeUsage`: a real adapt run on a full quote is ~$0.05–0.07 (in ~3-4.4k /
out up to 8k). Not trivial per click.

Also found in `error.log`: `messageTemplateSave.php` had a PRE-EXISTING PHP 8 fatal
(`$_SESSION[userid]`, bare constant) that silently ate template saves; the Phase 2a rewrite fixed
it incidentally. And `ClaudeUsage - verbose log write failed` fires on every call (all tools, not
just quoteNotes) — the diagnostic log is unwritable, still OPEN.

Prod release note id **145**, `status=0`. Docs screenshots regenerated (4 figures, annotated with
ImageMagick per [[browser_screenshot_tooling]]).

---

**Adapt-mode timeout, found in prod 2026-07-27 (Otter Fencing, companyid 29, db `108-1481181613`).**
`ClaudeUsage::call` had a flat `CURLOPT_TIMEOUT => 60`. Adapt = sonnet-5 @ 8000 max_tokens, which
on a real quote takes **>60s**, so curl aborted, `$resp === false`, and the user got the generic
"AI request failed. Please try again." Diagnosed straight from `evolution.claude_usage`: three rows
with `outcome=error`, `http_status=0`, `duration_ms` 60002/60003/60004 — a dead-flat 60s is always
a curl timeout, and that table is the fastest way to triage any AI tool failure.

Fix: `ClaudeUsage::call($payload, $tool, $sourceId, $opts)` now takes `["timeout"=>int,
"stream"=>bool]`. Long calls **stream** (SSE via `CURLOPT_WRITEFUNCTION`) and are reassembled by a
new private `assembleStream()` into the SAME shape as a non-streamed response, so callers are
unchanged. Streaming matters because a non-streamed generation sends **zero bytes until it
finishes** — indistinguishable from a hang. Stall detection is `CURLOPT_LOW_SPEED_LIMIT=1` /
`LOW_SPEED_TIME=60`, not the hard ceiling. `claudeQuoteNotes::MODE_TIMEOUT` = items 60 / style 90 /
adapt+strict 240.

Gotchas baked into `assembleStream()`: usage arrives in **two halves** (input+cache on
`message_start`, final `output_tokens` on `message_delta`) and must be merged or the call is
mispriced; `thinking_delta`/`signature_delta` carry no `"text"` key and must not touch the block;
a stream ending with **no `stop_reason`** means it was cut off — treat as failure, never return a
half document.

Verified live against the actual failing quote (77569, 28 line items, template 33): **64.6s
elapsed, 6561 output tokens, $0.1117** — i.e. genuinely past the old ceiling. Browser e2e then
confirmed the whole path incl. metering. Dev was silently near the edge too (runs at 41.5/50.6/57.8s
and one 60s timeout).

**My "template silently clipped" diagnosis was WRONG** — worth remembering. `sanitizeHtml()` runs
BEFORE the `MAX_TEMPLATE_CHARS` clip, and Otter's 44,749-byte template is Word-markup bloat that
sanitizes to 3,839 bytes (**100% of visible text retained**). It was never clipped. Always measure
post-sanitize size, not raw column length. Cap still raised 12000→40000 as headroom, and a
`clipped` flag + distinct UI warning added, but neither applied here.

Also added: `set_time_limit(300)` in the quoteditsave.php handler (defensive — on Linux
`max_execution_time` excludes network wait), elapsed-second counter in the modal, and a
timeout-specific error message instead of the generic one.

**Confirmed by Shane 2026-07-27:** there is **no proxy/CDN in front of prod**, so the 240s
PHP-side ceiling is the real limit and no async/polling design is needed. The per-tenant
`messagetemplates_ai_instructions.sql` migration has been **run on all tenants**. Shane deployed
the fix himself (commit + push).

Deploy note for any JS change in this repo: evolution has **no minified module JS** (unlike
[[portal_minified_no_build]]) — `index.php` serves `assets/js/modules/<page>.js` directly with an
hourly cache-buster (`?seq=b` . date('Ymdh')), so a user mid-hour may need a hard refresh.
