Jev call copilot CurrencyTransfer research and architecture built 2026-09-24

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:

  1. 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; reference copilot/constants.py header: "Change a coefficient here, not a prompt").
  2. 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.
  3. 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").
  4. Pin the model version (jev-1.13.0, not jev-latest) and log response.model (models.md "Aliases"; reference constants.py MODEL).

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

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):

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:

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

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

  1. Ranked calls (composite, outcome, scenario, engine, RM) with a per-call timeline identical to the live dashboard (reuse the reference's eval/timelines.json shape per call).
  2. Episode browser: every concern episode with topic, Jev's handling_quality, resolved, exemplar_candidate, and the transcript excerpt.
  3. 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 exists Noul (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").


  1. 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).
  2. 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.
  3. Harness L1/L2/L4 on those labels; iterate the question bank until ≥ 90 %.
  4. 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.
  5. Score the full backlog; add outcome joins; L3; tune weights.
  6. Later: Vectorize exemplar search, autoresearch feature discovery against outcomes, nova-3 live streaming.

8. Open questions for Stevan

  1. 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.
  2. 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.
  3. Compliance rules that define "overpromising" / non-compliant phrasing, so the response_compliant question can carry them as structured criteria.
  4. 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").
  5. 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.
  6. 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.
  7. Whether D1 FTS5 (available, including fts5vocab) is good enough as the exemplar search (no Vectorize) for the first version.
  8. 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).
6,056 words · Internal working documents. Do not share outside CurrencyTransfer.