Corpus + curation layer for the CurrencyTransfer call copilot
Research memo, 2026-09-24. Scope: how to turn hundreds of mixed-quality historical call
transcripts into (a) an admin-curated definition of "good", (b) grounded playbook lines,
objection responses and must-say checklists for the live copilot, and (c) a scorer that can
evaluate any call, historical or live, against that standard. Everything runs on Cloudflare
with Jev via the Workers AI binding (env.AI.run('typesafe/jev', …)).
Sources are cited inline as file paths (local vendor docs under
/home/stevan/dev/jev/docs/vendor/…, the reference app under
/home/stevan/dev/jev/reference/jev-sales-copilot/…) or URLs. Anything not backed by a
source is marked ASSUMPTION. Numbers from Cloudflare limits pages were fetched live on
2026-09-24 and are not in the local vendor folder; re-check before relying on them.
0. Constraints that shape every decision
| Constraint | Value | Source |
|---|---|---|
| Jev context | 64k tokens per request; 32k for state + the longest single question |
vendor/typesafe/models.md "Context length" |
| Jev on Workers AI | model page says "Context Window 32,000 tokens" — assume the tighter 32k applies end-to-end until measured | vendor/cloudflare/ai__models__typesafe__jev.md |
| Choice options | max 255 per Choice | vendor/typesafe/api.md "Choice" |
| Score levels | 2–10 | vendor/typesafe/api.md "Score"; 11 levels returns a server error per cookbooks__autoresearch_feature_discovery.md |
| Price | $0.042 / Mtok input, output free (TypeSafe direct); Cloudflare pricing in dashboard | vendor/typesafe/models.md; CF model page |
| Rate limits (direct API) | 250k tokens/s, 1,200 req/min, "adjusting dynamically" | vendor/typesafe/models.md |
| Rate limits (Workers AI binding) | no typesafe/jev row on the Workers AI limits page; Jev is catalogued under "Text Generation", whose default is "300 requests per minute, unless the model requires the Workers Paid plan" — the effective limit is unmeasured (open question §8) |
Workers AI limits (fetched 2026-09-24); vendor/cloudflare/ai__models__typesafe__jev.md "Text Generation • typesafe" |
| Customisation | none: "Jev is not fine-tuned or LoRA-adapted with customer data … shape its answers through the request" — state, instructions/criteria, decomposition + code | vendor/typesafe/models.md "Customizing Jev" |
| Data handling | "Jev is not trained on customer requests or responses"; ZDR for enterprise | vendor/typesafe/models.md "Data handling" |
| Failure modes | literal reading, no arithmetic/counting, no date maths, indirection, context rot on large state, adversarial content, no structural invariants (Noul ≠ Choice yes), no generation | vendor/typesafe/model-jaggedness__jev-1.13.md |
| Consistency | per-question probability std-dev ≈ 0.010 (Noul) / 0.0098 (Choice) across 15 re-runs; still flips near thresholds → use an "uncertain" band | cookbooks__consistency_noul_cookbook.md, cookbooks__consistency_choice_cookbook.md |
| Reference app request size | ~7,500 input tokens per utterance, ~37 questions, 12-utterance rolling window, ≈ $0.0003/request, 345–390 ms mean | reference README.md, eval/timelines.json |
Design consequences used throughout:
- Jev classifies and ranks; code stores, counts, weights, and decides. Every number an
admin can tune is a coefficient in code/DB, never a prompt rewrite (
patterns__composite-scoring.md; referencecopilot/constants.pyheader: "Change a coefficient here, not a prompt"). - Small, filtered state. Retrieve and filter in code before asking (
model-jaggedness__jev-1.13.md§"Large state full of irrelevant detail"). Never send a whole 40-minute call as one state. - Store raw answers. Re-weighting, re-thresholding and re-ranking must be free
(reference:
set_weights→ "recomputed with no extra Jev calls";cookbooks__classifying_rag_passages.md: "re-routing every passage costs no API calls"). - Pin the model version (
jev-1.13.0, notjev-latest) and logresponse.model(models.md"Aliases"; referenceconstants.pyMODEL).
1. Transcript ingestion + normalisation
What we are ingesting
ASSUMPTION: the "call coach" server holds one file per call (text or JSON), some with speaker labels and timestamps, some without; audio may or may not be retrievable. The scenario (onboarding vs customer success) is probably not labelled. Everything below tolerates missing timestamps and missing speaker labels; it does not tolerate missing text.
Target canonical format
One JSON document per call, compatible with the reference loader
(reference/copilot/replay.py accepts {"id","title","utterances":[{"t","speaker","text"}]}),
extended with what the corpus layer needs:
{
"call_id": "cc-2026-03-14-0173",
"scenario": "onboarding", // onboarding | customer_success | unknown (Jev-assigned, admin-confirmable)
"scenario_confidence": 0.93,
"source": {"system": "call-coach", "path": "…", "audio_r2_key": "raw/audio/…wav|null"},
"rep_id": "rm-07", "client_ref": "ct-acct-hash-…", // pseudonymised
"recorded_at": "2026-03-14T10:02:00Z",
"transcription": {"engine": "call-coach-original|@cf/deepgram/nova-3", "diarised": true},
"utterances": [
{"i": 0, "t": 0.0, "t_end": 4.1, "speaker": "rep", "text": "…", "words": 18, "pii_redactions": []},
{"i": 1, "t": 4.3, "t_end": 9.0, "speaker": "client", "text": "…", "words": 27, "pii_redactions": ["IBAN"]}
],
"outcome": {"first_trade_within_30d": true, "traded_gbp": 12000} // joined from CT DB, optional
}
Decision 1a — where transcripts come from
| Option | How | Pros | Cons |
|---|---|---|---|
| A. Use call-coach transcripts as-is | Pull files, normalise in a Worker/scripts/ job |
Zero transcription cost; fastest start | Unknown quality; speaker labels may be absent or wrong; no word timestamps |
| B. Re-transcribe from audio on Workers AI | @cf/deepgram/nova-3 with diarize: true, utterances: true, punctuate: true, smart_format: true, language: "en-GB" |
Consistent diarisation + utterance segmentation; same engine we'd use live; $0.0052/audio-minute HTTP (nova-3 model page) | Needs audio; 500 calls × 25 min ≈ $65 (ASSUMPTION on durations); nova-3 page does not document the response's word/speaker schema in prose — verify against its raw output schema |
| C. Hybrid (recommended) | A for text-only calls, B wherever audio exists; both land in the same canonical format with transcription.engine recorded |
Maximises coverage; lets the eval harness compare engines | Two code paths for a while |
Recommendation: C. Keep transcription.engine on every call so rubric scores can be
segmented by engine; if call-coach transcripts score systematically differently, that is a
transcription artefact, not a coaching signal.
Decision 1b — speaker labels (rep vs client)
Diarisation gives speaker 0/1/…, not roles. Options:
| Option | Mechanism | Notes |
|---|---|---|
| A. Heuristic in code | Speaker who says the company name / "CurrencyTransfer" / "my name is" in the first 60 s is rep; longest-talking speaker early is rep |
Cheap; fails on transfer/hold/three-way calls |
| B. Jev Choice per speaker cluster (recommended) | State = first 8–10 turns of each diarised speaker; one Choice per speaker: {rep: "an employee of the currency company greeting, introducing themselves, or explaining the service", client: "a customer or prospect asking about rates, transfers, their account…", other: "a third party, IVR, or hold music transcription"}; act only when confidence ≥ 0.8, else queue for admin |
One request per call, hundreds of tokens; patterns__confidence-routing.md pattern |
| C. Admin labels everything | UI toggle like the reference's live-mic "Rep/Prospect" switch | Only as fallback for B's low-confidence tail |
When the source already has role labels, keep them but still run B and flag disagreements.
Decision 1c — utterance segmentation
- Unit = one diarised turn (
utterances: truefrom nova-3, or one line from call-coach). Merge consecutive same-speaker turns separated by < 1.0 s (ASSUMPTION threshold; tune by eye on 20 calls). - Split any turn > 120 words at sentence boundaries into sub-utterances that share
t. The reference flags a rep monologue at 70 words (constants.pyREP_MONOLOGUE_WORDS); keeping utterances short also keeps per-utterance questions literal and cheap. - Missing timestamps: synthesise as the reference does (
replay.py:max(3.0, words/2.5)seconds). - Segments for the rubric = sliding windows of 12 utterances (matches reference
RECENT_WINDOW = 12), plus episodes = code-detected spans starting at a client concern and ending at the next client turn that accepts/moves on (see §2). Episodes are the unit admins tag.
Decision 1d — PII handling
CurrencyTransfer calls will contain IBANs, sort codes, account numbers, card numbers, addresses, DOBs, passport numbers, beneficiary names and transfer amounts. ASSUMPTION: UK GDPR applies and the company has a lawful basis to process recordings for training/QA; confirm with compliance before anything leaves the call-coach box.
| Option | Mechanism | Verdict |
|---|---|---|
| A. Redact in code before storage (regex + Luhn/IBAN mod-97 + UK sort-code/account patterns + phone/email/postcode + known client name list from CT DB) | Deterministic, auditable, zero model cost | Necessary but not sufficient (spoken numbers: "four two, one one, oh nine") |
| B. Jev Noul sweep on every utterance after A | pii_present: "Does utterance.text still contain a personal identifier: a full name, address, date of birth, account/IBAN/sort code, passport number, phone, or email, whether written as digits or spoken as words?" Route noul ≥ 0.5 to a redaction queue (admin or a generative model) |
Cheap: ~150k utterances × ~300 tokens ≈ $2 direct-API price; Jev doesn't generate replacements, a human or an LLM does |
| C. Pseudonymise, don't delete | Replace with typed tokens [IBAN], [NAME_1], [AMOUNT] and keep a per-call mapping in a separate, access-controlled store |
Keeps the transcript readable for Jev (a [AMOUNT] token still lets "quoted a rate" questions work) |
Recommendation: A → B → C in that order; only pseudonymised text is ever sent to Jev,
stored in D1, or shown in the admin UI. Raw audio/transcripts live in a private R2 bucket
with a retention rule (§5). Jev's own posture ("not trained on customer requests",
models.md) is reassuring, but on Workers AI the model is listed as "Third-party" — check
Cloudflare's third-party-model terms before sending even pseudonymised text (open question §8).
Decision 1e — scenario classification (onboarding vs customer success)
One Choice per call, state = first 15 utterances + last 5 (not the whole call: context rot):
{onboarding: "a first or early call: explaining how the service works, account setup, verification/KYC documents, first transfer walkthrough", customer_success: "an existing client: reviewing recent transfers, rate check-in, complaint, retention, upsell", other: "…"}.
Gate on confidence like cookbooks__classification_using_confidence.md (≥ 0.9 accept,
else unknown → admin). Store scenario on the call; the rubric and playbook are keyed by it.
2. Jev-based call-quality rubric
Structure: three layers, all atomic, composite in code
The reference app scores closing probability. We score call quality against the CT
standard, which is a different composite but the same machinery
(patterns__composite-scoring.md: "break the judgment into independent dimensions, score
each one separately, and combine them with weights you control in code").
Layer 1 — utterance level (one request per utterance, speculative fan-out, all questions
every turn, masked by speaker in code as the reference does; patterns__fan-out.md).
State per request (mirrors reference; ~5–8k tokens):
{
"call_facts": {"scenario": "onboarding", "minute": 6.2, "talk_ratio_rep": 0.58,
"known_facts": ["fee_structure_explained"], "open_concern": {"topic": "rate", "since_utt": 41}},
"recent_transcript": [ …last 12 utterances… ],
"latest_utterance": {"i": 47, "speaker": "client", "text": "…"}
}
Questions (illustrative wording; the concrete must-say list is ASSUMPTION until Stevan / compliance supply it — see §8):
- Client-turn Nouls:
client_raises_concern,client_accepts("acknowledges the rep's last answer as satisfactory"),client_confused,client_disengaging,client_asks_to_proceed(buying/booking signal),client_asks_price(rate/fee question). - Client-turn Choice
concern_topicover{rate, safety_of_funds, settlement_timing, fees, process_or_documents, competitor, none}withwhat/not_for/examplesper option (concepts__how-to-build-with-system-one.md"define contrastive Choice criteria"). Asked speculatively; only opens a concern whenclient_raises_concern ≥ 0.6(reference pattern:objection_typeis speculative,prospect_objectinggates it). - Rep-turn Nouls:
rep_answered_the_concern("doeslatest_utterance.textdirectly address the concern incall_facts.open_concern?"),rep_asked_open_question,rep_monologuing(also computed in code from word count),rep_quoted_specific_figure("states a rate, fee, margin, or timeframe as a concrete number"),rep_overpromised("guarantees a future rate or outcome nobody can guarantee" — ASSUMPTION this is a compliance no-no),rep_confirmed_understanding. - Rep-turn Score
answer_quality(3–4 concrete levels: "ignores or deflects the concern" / "answers vaguely without specifics" / "answers with a specific, correct-sounding explanation" / "answers with specifics and checks the client is satisfied"). Only meaningful when a concern is open — code masks it otherwise. - Window Nouls for must-say facts (durable, persisted in code once ≥ 0.7 like the
reference's
FACT_PERSIST_THRESHOLD): e.g.safeguarding_explained,fee_structure_explained,settlement_timeline_explained,rate_lock_explained,next_step_agreed. Each referencesrecent_transcriptand lists true/false examples. - Scores over the window:
rapport,client_engagement,clarity_of_rep(levels as concrete situations withsignals, as in referenceconstants.py). - Choice
stagefor the scenario (onboarding:opening, needs_discovery, service_explanation, verification_docs, first_transfer_walkthrough, concern_handling, next_steps; CS: its own list).
Layer 2 — episode level (code + one request per episode). Code cuts an episode at each
opened concern: from the client utterance that opened it to the first later client turn
with client_accepts ≥ 0.6 or client_asks_to_proceed ≥ 0.6, capped at 8 utterances
(reference OBJECTION_MAX_AGE_UTTERANCES = 8). Then one request per episode with state =
{concern: {topic, client_text}, rep_response_turns: [...], resolution_turn: {...}} and
questions:
resolvedNoul ("did the client end the exchange satisfied, judging byresolution_turn?")response_specific,response_accurate_sounding,response_compliantNouls (the last with the compliance rules pasted into the question as structured fields, not the state —api.md"instructions can be an object … refer to data fields by name in backticks")handling_qualityScore (4 levels, concrete)exemplar_candidateNoul ("would a sales trainer show this exchange to new reps as a model of how to handle this concern?") — deliberately a trainer's judgment, kept separate from the atomic ones so the admin can see when they disagree.
Episodes are what admins tag ("great rate objection handling"), what feeds the playbook (§4), and what the eval harness regression-tests (§6).
Layer 3 — call level (code only, no Jev). Composite:
quality = Σ_k w_k · x_k
x from Layer 1: mean(answer_quality | concern open), mean(client_engagement), 1 − mean(client_confused),
1 − mean(rep_overpromised), 1 − talk_ratio_penalty(code), fact_coverage (code: fraction of
scenario must-say facts persisted), episodes_resolved / episodes_opened (code)
x from Layer 2: mean(handling_quality), share of episodes with response_compliant ≥ 0.7
Weights start as the admin's guess (a table in D1, versioned) and are tuned on labelled calls (§3, §6). Everything is recomputable from stored raw answers.
Decision 2a — rubric granularity
| Option | Description | Verdict |
|---|---|---|
| A. Whole-call questions | One request per call with "how good was this call?" Scores | Violates jaggedness guidance (large state, broad question hides several judgments); no moment-level output; rejected |
| B. Utterance + episode + code composite (above) | ~37 questions/utterance like the reference, plus ~6/episode | Recommended; identical machinery for historical batch and live |
| C. Generative-LLM judge | Claude/GPT writes a rubric verdict per call | Useful once to bootstrap labels or as a second opinion in the admin UI; ~40× the cost and inconsistent run-to-run per the consistency cookbooks; never the production scorer |
Decision 2b — flagging candidate "good" calls and "bad" moments
- Candidate good calls:
quality ≥ P80of the corpus and (if outcomes are joined) a good outcome, and at least one episode withexemplar_candidate ≥ 0.7. Ranked list to the admin, top 30 first. Outcome join: onboarding → client traded within N days; CS → client traded again / no churn. ASSUMPTION: feasible via the CT production DB (thect-sqlskill'sclients/trade_bookingstables) using a hashed client reference; the call-coach data must carry something joinable (email, account id, RM + date). - Bad moments: any episode with
resolved < 0.3, orrep_overpromised ≥ 0.7, orresponse_compliant < 0.3, or clientconfused ≥ 0.7twice within a window. Surface as a second list ("what to avoid"); admins tag these too — negative exemplars becomenot_fortext andfalsecriteria examples (§3). - Uncertain band: treat Noul 0.30–0.70 as "uncertain", don't auto-flag on it
(
cookbooks__consistency_noul_cookbook.md), and Choice top-probability < 0.60 as "uncertain" (cookbooks__consistency_choice_cookbook.md). Uncertain episodes are the most valuable for admin review: they are where the criteria are ambiguous.
Cost of scoring the backlog
ASSUMPTION: 500 calls × 250 utterances = 125k utterance requests at ~7k tokens = 875M
tokens ≈ $37 at the direct-API price, plus ~2k episode requests (negligible). At the direct
API's 1,200 req/min this is ~2 h wall-clock if run flat out; the Workers AI binding has no
typesafe/jev rate-limit row and the Text Generation default is 300 req/min, which makes it
~7 h — the real limit is unknown until measured (build step 1, §8). Run it as a Cloudflare Queue consumer or a
Workflow with concurrency ≤ 10 and idempotent per-utterance writes. Windows overlap, so
window-level facts could be asked every 4th utterance in batch mode to cut cost ~3×; the
live path keeps every-turn.
3. Admin curation loop (no retraining; state + instructions + criteria + weights)
What the admin sees
- Ranked calls (composite, outcome, scenario, engine, RM) with a per-call timeline
identical to the live dashboard (reuse the reference's
eval/timelines.jsonshape per call). - Episode browser: every concern episode with
topic, Jev'shandling_quality,resolved,exemplar_candidate, and the transcript excerpt. - Semantic find inside one call: "where does the rep explain safeguarding?" → Choice
over utterance ids (≤ 255 per request, so a 300-utterance call is split into two windows)
plus an
existsNoul (cookbooks__semantic_find.md— exactly this recipe).
What the admin can do, and what it changes
| Admin action | Stored as | Effect on behaviour (no retraining) | Cost to apply |
|---|---|---|---|
Tag an episode exemplar with a label from a fixed taxonomy (scenario × topic × quality ∈ {model, acceptable, avoid}) + optional note |
exemplars row (D1) pointing at call_id, utterance span, label, admin, timestamp |
Becomes (i) a gold label for the eval harness, (ii) a candidate examples snippet inside a Choice/Noul criterion, (iii) source text for a playbook line (§4) |
zero Jev calls until promoted |
Confirm/override Jev's concern_topic, scenario, speaker roles |
labels rows |
Gold labels for the harness; disagreements ≥ N on one question = "rewrite this criterion" signal | zero |
| Edit weights / thresholds | rubric_versions.weights JSON |
Whole corpus re-ranked from stored raw answers | zero Jev calls (reference set_weights) |
Edit a question's instructions / criteria / examples |
new question_bank version (immutable rows; rubric_hash like the consistency cookbooks' _rubric_fingerprint) |
Changes answers; must re-run affected questions on the labelled set and pass the harness before promotion | re-score labelled calls only (~50 calls ≈ $4) |
| Add a must-say fact for a scenario | new Noul in question_bank + weight |
Coverage metric + live checklist item | re-score labelled set |
| Approve a playbook line / objection response | playbook_versions |
Live copilot Choice options change (§4) | zero for the rubric; live path re-reads KV |
Decision 3a — how exemplars steer Jev
| Option | Mechanism | Pros | Cons |
|---|---|---|---|
A. Exemplars as examples inside criteria (recommended for judging) |
Each Choice option / Noul side carries 2–4 short admin-approved snippets: {what, not_for, examples:[…]} (concepts__how-to-build-with-system-one.md §"Use structure in the questions") |
Directly sharpens the criterion; tiny token cost; versioned with the question | Snippets must be short (one sentence); too many examples per option = context rot; picking them is an editorial job |
B. Exemplars in state as few-shot references |
state.reference_examples = [...] alongside the transcript window |
Can carry longer excerpts | Bloats every live request; "unrelated material in the state costs you accuracy" (model-jaggedness__jev-1.13.md); rejected for live |
| C. Exemplars as a retrieval corpus (recommended for suggesting) | Stored with tags + embeddings; live path shortlists by tags/similarity, Jev picks (§4) | Scales to thousands; text shown to the rep is real, admin-approved | Needs the retrieval layer |
Recommendation: A + C. Judging questions get 2–4 examples per option drawn from tagged
exemplars (admin ticks "use as criterion example"); suggestion text comes from C.
Decision 3b — criteria authoring workflow
| Option | Description | Verdict |
|---|---|---|
| A. Admin edits question text directly in a form | Simple; mirrors reference constants.py |
Fine for weights and examples; risky for instruction wording (literal reading, contradictory instruction vs criteria — model-jaggedness §7) |
| B. Admin describes intent; an LLM (Opus/Sonnet) drafts the Jev question in the house style; Jev answers on the labelled set; harness scores it; admin promotes | The autoresearch loop's propose → answer → measure shape (cookbooks__autoresearch_feature_discovery.md) with a human gate |
Recommended: the harness makes wording changes safe |
| C. Fully automatic autoresearch against outcome labels | LLM proposes questions, Jev answers, CatBoost/logistic regression picks features that predict good outcomes; loop | Phase 2, once ≥ 300 labelled/outcome-joined calls exist; the cookbook's gains came from 1,200 labelled rows; screen candidates with Nouls before paying (cookbook "Next steps") |
Decision 3c — ranking the admin's review queue
Order the queue by information value, not by score: (1) episodes in the uncertain band,
(2) top-composite calls without any admin label yet, (3) bottom-composite calls, (4)
disagreements between exemplar_candidate (trainer's-eye Noul) and the atomic composite.
Reviewing ~60 episodes gives the first labelled set (the autoresearch cookbook reads 60
examples per proposal round; the reference's integration test uses 19 labelled utterances /
56 checks and reaches 100%).
4. From curated exemplars to the live copilot
Playbook shape
Same JSON contract as the reference (reference/data/playbook.json: moves[] with id,
title, what, not_for, phrasings[]), keyed by scenario and extended:
{
"version": 12, "scenario": "onboarding",
"moves": [
{"id": "handle_rate_concern_with_transparency", "title": "Explain how the rate is set",
"what": "The client says the rate looks worse than the bank/competitor or asks why it moved. Explain margin vs interbank plainly and offer the live quote.",
"not_for": "Fee questions, safety questions, or a client who has already accepted the rate.",
"topic": "rate",
"phrasings": [
{"id": "p1", "text": "…admin-approved line…", "source_exemplar": "cc-…#41-44"},
{"id": "p2", "text": "…", "source_exemplar": null}
],
"must_say": ["rate_is_indicative_until_booked"]}
],
"checklists": {"onboarding": ["safeguarding_explained", "fee_structure_explained", "settlement_timeline_explained", "next_step_agreed"]}
}
Playbook phrasings are admin-approved text, ideally lifted verbatim from a tagged
exemplar (with the [NAME]/[AMOUNT] tokens cleaned). Jev never writes them; it only picks
(README.md: "Jev picks; code shows the text"). Optional generative tailoring stays exactly
as the reference does it: off the critical path, verified by Jev invents_fact /
on_move Nouls, dropped if stale (reference/copilot/personalize.py, constants.py
VERIFY_QUESTIONS).
Decision 4a — how the live path selects a move and a line
| Option | Mechanism | Fits within budget? |
|---|---|---|
| A. One Choice over all moves for the scenario (≤ ~20) + one speculative phrasing Choice per move over its ≤ 5 lines | Exactly the reference (constants.py: next_move Choice + phrasing::<move> Choices; ~1,900 extra tokens for phrasings) |
Yes: 20 moves × 5 lines ≈ 100 options ≈ 3–4k tokens |
| B. Code pre-filter, then Choice (recommended) | Code narrows candidate moves by scenario, stage, open_concern.topic, known_facts (e.g. drop explain_fees once fee_structure_explained is locked, as the reference feeds known_facts back) to ≤ 8 moves; phrasing Choices only for those |
Yes, and less context rot; the Choice is relative, so shortlist quality matters more than size |
| C. Vectorize/BM25 shortlist → Jev rerank per (moment, line) pair | Embed lines with @cf/baai/bge-base-en-v1.5 (768-d) in Vectorize; query with the last client turn; top-20 → Choice, or per-pair Noul rerank (cookbooks__rerank_typesafe.md: BM25 top-30 then Jev per pair; cookbooks__classifying_rag_passages.md: cosine top-12 then 4 Nouls per pair) |
Only needed when the retrievable library is > ~200 lines or is raw exemplar excerpts rather than curated lines; per-pair calls cost latency (1 request each) — do it on the admin path, not per utterance live |
Recommendation: B for the live path; C for the admin/exemplar search tool and for a future "show me a real example" panel. Reasoning on the 32k limit: a curated playbook of 2 scenarios × 15 moves × 4 lines ≈ 120 lines ≈ 4k tokens, plus the ~5k-token window state and ~30 questions ≈ 15k — half the Workers AI budget, no retrieval layer needed. Vectorize's limits (20M vectors, 1536 dims, 10 KiB metadata, topK 50 with metadata — Vectorize limits) are far beyond what exemplars need; the reason to defer it is complexity, not capacity.
Decision 4b — must-say checklist on the dashboard
Code-only over persisted facts: for the current scenario, show each checklist item as
unsaid / said (locked ≥ 0.7) / uncertain (0.3–0.7); suggest the move whose must_say
covers the oldest unsaid item once the stage is past discovery. This is the "what they
should remember to say" bar from the brief and needs no extra Jev questions beyond the
window Nouls in §2.
Decision 4c — objection-specific responses
Per (scenario, topic) keep 3–6 approved lines. When a concern opens with
concern_topic.confidence ≥ 0.4 (reference OBJECTION_TYPE_MIN_CONFIDENCE), the
next_move shortlist is restricted to that topic's moves, and the phrasing Choice ranks its
lines against recent_transcript. Below confidence, show the generic "acknowledge and
clarify the concern" move (reference: "listening…" gate at NEXT_MOVE_MIN_CONFIDENCE = 0.35).
5. Storage on Cloudflare
Limits fetched 2026-09-24 from the Cloudflare docs (not in the local vendor folder): D1 — 10 GB/database (Paid), 2 MB max row, 100 bound params/query, 100 KB statement, 1,000 queries/invocation; KV — 25 MiB value, 1 write/s per key; eventually consistent, changes visible in "up to 60 seconds or more"; R2 — 5 TiB objects, unlimited bucket size; Durable Objects — 10 GB SQLite storage per object, soft 1,000 req/s per object, 32 MiB WebSocket messages (received messages only); Vectorize — 20M vectors/index, 1536 dims, 10 KiB metadata, topK 50 with metadata.
Size estimate (ASSUMPTION 500 calls, 250 utterances each): utterances ≈ 125k rows × ~0.5 KB = 60 MB; raw Jev answers per utterance (37 questions with probabilities) ≈ 3 KB → 375 MB; episodes ≈ 2–5k rows. All well inside one D1 database.
| Data | Recommended store | Alternatives considered | Why |
|---|---|---|---|
| Raw audio, raw (un-redacted) transcripts, nova-3 raw JSON | R2 private bucket, object lifecycle rule for retention, key = raw/{call_id}/… |
D1 (2 MB row cap, and raw PII in a query store is wrong); KV (25 MiB cap OK but no lifecycle/ACL semantics) | Large blobs, write-once, restricted access |
| Calls, utterances (pseudonymised), speaker labels, scenario | D1 tables calls, utterances |
DO SQLite per call (queryable only per object; no cross-call SQL); R2 JSON (no queries) | Cross-call queries: rankings, per-RM stats, label joins |
| Raw Jev answers per utterance and per episode | D1 answers(call_id, utt_i, rubric_hash, model, json) with json ≤ 3 KB; index on (rubric_hash, call_id) |
R2 one JSON per call (cheap, but re-weighting needs a scan); DO | Recompute-without-Jev must be a SQL read; keep rubric_hash + model so old answers are never mixed with new criteria (consistency cookbooks' cache-key discipline) |
| Rubric results (composite, per-version) | D1 call_scores(call_id, rubric_version, quality, features_json) |
compute on read | Cheap to recompute; storing per version lets the admin diff versions |
| Question bank, weights, thresholds (versioned) | D1 immutable question_bank_versions, rubric_versions; KV copy of the published version under rubric:v{n} and rubric:current |
Git-tracked JSON like reference constants.py (fine for dev, but admins edit in a UI) |
D1 is the source of truth; KV is the read-hot mirror the live Worker/DO reads once per session; KV eventual consistency is fine because keys are version-addressed, but the rubric:current pointer can lag up to 60 s or more behind a publish |
| Playbook versions | D1 + KV mirror (same pattern) | as above | as above |
| Exemplars, admin labels, review queue state | D1 exemplars, labels, review_items |
— | Relational; joins to calls/episodes |
| Exemplar embeddings (admin semantic search, later "real example" panel) | Vectorize index with namespace = scenario, metadata {topic, quality, call_id, span} |
BM25 in D1 FTS5 (supported, including fts5vocab) |
Only when the library outgrows tag filtering; BM25 via FTS5 may be enough and avoids another binding |
| Per-live-call session state (rolling window, EMA, facts, open concern, last 50 raw answers) + WebSocket fan-out to the dashboard | Durable Object (SQLite-backed), one per call | KV (1 write/s per key is too slow for per-utterance writes; eventual consistency); D1 (no affinity, no WebSockets) | Single-writer, strongly consistent, holds the WebSocket; the reference's in-process CallSession maps 1:1 onto a DO; flush the final timeline to D1/R2 on call end |
| Eval runs (timelines, integration reports) | R2 eval/{run_id}/timelines.json, summary row in D1 eval_runs |
repo-committed JSON like reference eval/ (keep for the golden subset in git) |
Runs are large and append-only |
| Job orchestration for backlog scoring | Queues (per-utterance messages, batch 10) or a Workflow per call | cron Worker looping (CPU limits: 30 s for sub-hourly crons, 15 min for hourly+; HTTP default 30 s, configurable to 5 min — still the wrong tool for a multi-hour backfill) | Idempotent retries; keeps under Workers AI/Jev rate limits |
Schema sketch (D1):
CREATE TABLE calls(call_id TEXT PRIMARY KEY, scenario TEXT, scenario_conf REAL, rep_id TEXT, client_ref TEXT,
recorded_at TEXT, engine TEXT, utterances INTEGER, duration_s REAL, outcome_json TEXT, raw_r2_key TEXT);
CREATE TABLE utterances(call_id TEXT, i INTEGER, t REAL, t_end REAL, speaker TEXT, text TEXT, words INTEGER,
pii_json TEXT, PRIMARY KEY(call_id, i));
CREATE TABLE answers(call_id TEXT, i INTEGER, rubric_hash TEXT, model TEXT, answers_json TEXT, input_tokens INTEGER,
latency_ms INTEGER, PRIMARY KEY(call_id, i, rubric_hash));
CREATE TABLE episodes(episode_id TEXT PRIMARY KEY, call_id TEXT, topic TEXT, start_i INTEGER, end_i INTEGER,
rubric_hash TEXT, answers_json TEXT);
CREATE TABLE call_scores(call_id TEXT, rubric_version INTEGER, quality REAL, features_json TEXT, PRIMARY KEY(call_id, rubric_version));
CREATE TABLE question_bank_versions(version INTEGER PRIMARY KEY, rubric_hash TEXT, questions_json TEXT, created_by TEXT, created_at TEXT, note TEXT);
CREATE TABLE rubric_versions(version INTEGER PRIMARY KEY, weights_json TEXT, thresholds_json TEXT, question_bank_version INTEGER, published INTEGER);
CREATE TABLE playbook_versions(version INTEGER, scenario TEXT, playbook_json TEXT, published INTEGER, PRIMARY KEY(version, scenario));
CREATE TABLE exemplars(exemplar_id TEXT PRIMARY KEY, episode_id TEXT, label TEXT, quality TEXT, note TEXT, use_as_example INTEGER,
admin TEXT, created_at TEXT);
CREATE TABLE labels(label_id TEXT PRIMARY KEY, call_id TEXT, i INTEGER, question_id TEXT, gold_json TEXT, admin TEXT, created_at TEXT);
CREATE TABLE eval_runs(run_id TEXT PRIMARY KEY, rubric_version INTEGER, model TEXT, pass_rate REAL, spearman REAL, cost_usd REAL, r2_key TEXT, created_at TEXT);
Gotchas: D1's 100 bound parameters per statement means batch inserts of ≤ 12 utterance rows
per statement (8 columns each); use batch() for the rest. answers_json must stay under
2 MB (it will be ~3 KB). KV rubric:current is a pointer, never the mutable document.
6. Evaluation harness (regression-testing question-bank changes)
Modelled on the reference's two real-API test layers: tests/test_integration_jev.py
(19 hand-labelled utterances, 56 threshold checks, ≥ 90 % pass gate, writes
eval/integration_report.json) and tests/test_e2e_replay.py (replays whole calls, writes
eval/timelines.json, asserts shapes: good ends ≥ 0.70, bad ≤ 0.40, mixed dips ≤ 0.30
then ends ≥ 0.65, p95 latency < 2 s, cost < $0.05/call, model pinned, zero errors).
What is under test
Every promotion of a question_bank_version / rubric_version / model version, plus a
nightly run. Inputs are frozen: the golden set is a snapshot (call ids + utterance ids +
gold labels + expected shapes), exported from D1 to R2 and a small copy committed to the
repo so CI can run without DB access.
Decision 6a — test layers
| Layer | Gold source | Check | Gate |
|---|---|---|---|
| L1 Labelled utterances | admin labels (concern topic, concern raised, accepts, must-say facts, speaker role, scenario) |
threshold checks per case exactly as integration_report.json ("check": "prospect_objecting >= 0.6", "ok": true) |
≥ 90 % pass; no regression > 2 pts vs the previous published version |
| L2 Labelled episodes | admin exemplars with quality ∈ {model, acceptable, avoid} |
handling_quality ordering: mean(model) > mean(acceptable) > mean(avoid) with margin; exemplar_candidate ≥ 0.7 on ≥ 80 % of model |
hard gate on ordering |
| L3 Call ranking | admin-ranked ~30 calls (pairwise or 1–5 rating) + outcomes | Spearman(composite, admin rank) ≥ 0.6 (ASSUMPTION target; the autoresearch cookbook reports Spearman 0.78–0.80 for its task); AUC of composite vs outcome ≥ 0.65 | soft gate, reported |
| L4 Call shapes | 3–5 hand-picked calls per scenario (one clean, one bad, one recovery) | same assertions as test_e2e_replay.py, on quality and on checklist coverage by the end of the call |
hard gate |
| L5 Stability | 5 re-runs of L1 with a fresh uid in state (consistency cookbooks' recipe) |
per-question std-dev ≤ 0.03; count decisions crossing a threshold | report; a question that flips is a criterion to rewrite |
| L6 Budget | every run | mean/p95 latency per request (Workers AI path), tokens/request ≤ 12k, cost/call ≤ $0.03 (ASSUMPTION budget) | hard gate on tokens (protects the 32k limit) |
Decision 6b — where the harness runs
| Option | Pros | Cons |
|---|---|---|
A. Node script in this repo hitting Workers AI REST (scripts/jev.mjs already does POST /accounts/{id}/ai/run) |
Runs in GitHub Actions; same model path as production | Needs CF token in CI secrets; rate limits shared with prod |
B. A Worker endpoint /eval/run that reads golden from R2 and writes results to D1/R2, triggered by the admin UI "Promote" button (recommended, alongside A for CI) |
Admins see pass/fail before publishing; no DB export needed | Long runs must be a Workflow/Queue, not one request |
| C. pytest against TypeSafe direct API (as the reference does) | Reuses reference tests nearly verbatim | Different endpoint from production; useful only for A/B-ing Workers AI vs direct |
Cache every (state, questions, model) → answers like the cookbooks' JsonCache keyed by
rubric_hash so re-running an unchanged question set costs nothing and so that a changed
criterion "busts the cache instead of silently serving a stale answer"
(cookbooks__consistency_noul_cookbook.md _rubric_fingerprint).
Decision 6c — how a change is promoted
draft → scored on golden (L1–L6) → admin reviews diff (which cases flipped, which exemplars moved) → publish (D1 published=1, KV pointer) → backfill the corpus in the background (Queue) → old answersrows kept under theirrubric_hash for rollback.
Model upgrades follow the same path with model as the changed variable (models.md:
"pin that version's ID … move to the new one on your own schedule").
7. Recommended build order
- Ingest 50 calls (mixed engines), pseudonymise, classify scenario + speaker roles,
store in D1/R2. Verify the 32k budget empirically on Workers AI with a 12-utterance
window + ~35 questions, and measure the effective Workers AI rate limit for
typesafe/jev(no documented row; the Text Generation default is 300 req/min). - Rubric v1 with placeholder must-say facts; score the 50; admin reviews the review queue (§3c) for one afternoon → ~60 labelled episodes, ~100 labelled utterances.
- Harness L1/L2/L4 on those labels; iterate the question bank until ≥ 90 %.
- Playbook v1 from tagged exemplars (verbatim lines, cleaned) → live copilot POC on a recorded call (replay mode from the reference) using a DO per session.
- Score the full backlog; add outcome joins; L3; tune weights.
- Later: Vectorize exemplar search, autoresearch feature discovery against outcomes, nova-3 live streaming.
8. Open questions for Stevan
- Call-coach server: file format(s), presence of audio, timestamps, speaker labels, and whether calls can be joined to CT accounts (email / account id / RM + date) for outcomes.
- The actual must-say list per scenario (safeguarding wording, fee disclosure, rate indicative-until-booked, settlement timelines, FCA-required statements). The rubric's must-say Nouls and the dashboard checklist are placeholders until this exists.
- Compliance rules that define "overpromising" / non-compliant phrasing, so the
response_compliantquestion can carry them as structured criteria. - Lawful basis and retention for recordings; whether pseudonymised transcripts may go to a
third-party model on Workers AI (Cloudflare marks
typesafe/jev"Third-party"). - Who the admins are and how many hours/week they can label; that sets whether option 3b-B (LLM-drafted questions, human-gated) or 3b-C (autoresearch) is realistic in the first quarter.
- Whether the Workers AI Jev path really enforces 32k rather than TypeSafe's 64k/32k split; this decides whether phrasing Choices for all moves can ride on every request or only the shortlisted ones.
- Whether D1 FTS5 (available, including
fts5vocab) is good enough as the exemplar search (no Vectorize) for the first version. - The actual Workers AI rate limit for
typesafe/jev: the limits page has no row for it and the Text Generation default is 300 req/min vs 1,200 req/min on the direct API; measure in build step 1, since it sets backlog wall-clock (~7 h vs ~2 h).