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

CT call copilot on Cloudflare + Jev: data-first proposal

Status: architecture proposal, 2026-09-24. Author: Fable 5.1 subagent (data-first angle). Feeds the otto-plan PRD. Everything not cited is marked ASSUMPTION. Citations are local file paths; guide §n = /home/stevan/dev/jev/docs/jev-guide.md, dissection §n = /home/stevan/dev/jev/docs/research/reference-copilot-dissection.md, brief §n = /home/stevan/dev/jev/docs/research/ct-domain-brief.md, corpus §n = /home/stevan/dev/jev/docs/research/corpus-and-curation.md, stt §n = /home/stevan/dev/jev/docs/research/stt-options.md, prior-art §n = /home/stevan/dev/jev/docs/research/prior-art.md.

1. Summary

  1. The product is a policy (question bank + playbook + weights + thresholds), not a dashboard. The dashboard is one consumer of the policy; the batch scorer over 3,121 usable historical calls is the other, and it comes first.
  2. One TypeScript engine (step()), pure and side-effect free, runs identically in a Queue consumer (historical calls) and in a Durable Object (replay/live). Same state shape, same questions, same gates.
  3. Jev only judges. Every number (checklist completeness, risk flags, call health, hysteresis, EMA, token budgets) is code. Raw Jev answers are stored per utterance under a rubric_hash, so weights and thresholds re-score the whole corpus with zero Jev calls.
  4. Curation loop: Jev tags moments (concern episodes, must-say moments, risk moments, trainer picks); Stevan marks them model / acceptable / avoid; marks become playbook lines, criterion examples, and gold labels for the eval harness.
  5. Outcomes join through call_records.pd_ct_id (populated on 82% of Onboarding and 64% of Customer Service records, measured today) to CT activation and first-trade tables, so weights and thresholds come from real CT calls, not guesses.
  6. Hero metrics per Stevan's decision: Onboarding = must-say checklist completeness + risk flags; Customer Success = resolution state + call health. No closing probability.
  7. Rep-facing text is pre-approved playbook lines by default; tailored rewrites are an off-critical-path, droppable path verified by four Jev nouls (invents_fact, on_move, makes_promise_or_guarantee, predicts_rate), behind a per-scenario flag.
  8. Token budget per Jev request is asserted in code at 16k (estimated at 4 chars/token) against the 32k Cloudflare limit; measured design point is ~8-9k.
  9. Budget: ~$0.03 per replayed call, ~$25 per 1,000 historical calls scored, ~$75 for the full usable corpus; batch wall-clock 1-4 h per 1,000 calls depending on the unmeasured Workers AI rate limit.
  10. M1 ships a replay demo powered by a policy derived from 100 scored, partially curated historical calls, plus the batch scorer, the eval harness L1/L4/L6, and audio upload via nova-3.

2. Design stance

The angle: optimise for the curation and evaluation loop. Reasons this is the right order for CT specifically:

What it costs: M1 has less UI polish than a dashboard-first plan and spends its first days on stitching, redaction and a scorer nobody sees. The replay demo still ships in M1, and it replays calls that were scored by the same engine.

3. System overview

flowchart LR
  subgraph Sources
    CC[(call-coach Postgres<br/>call_records / call_turns)]
    AUD[Uploaded audio<br/>mp3 / m4a / wav]
    CT[(CT prod DB<br/>broker_accounts, trade_bookings)]
  end

  subgraph Ingest["Ingest (Node script + Worker /ingest)"]
    NORM[normalise → stitch v1 → redact v1]
  end

  subgraph CF["Cloudflare account 694e…9ce9"]
    R2[(R2 raw:<br/>audio, Aircall JSON, nova-3 JSON,<br/>eval artefacts)]
    D1[(D1 copilot:<br/>calls, utterances, answers, steps,<br/>moments, marks, labels, outcomes,<br/>policy_versions, eval_runs, audit)]
    KV[(KV: policy:current,<br/>policy:v{n})]
    Q1[[Queue score-calls]]
    Q2[[Queue stt-jobs]]
    Q3[[Queue rewrite-jobs]]
    W[Worker api<br/>REST + WS upgrade + Queue producer]
    SC[Queue consumer<br/>batch scorer: step() per utterance]
    DO[Durable Object CallSession<br/>replay/live: step() per utterance, WS fan-out]
    AI[Workers AI<br/>typesafe/jev<br/>@cf/deepgram/nova-3]
    GW[AI Gateway jev-copilot<br/>logging off, spend limit]
    PG[Pages: dashboard + admin console<br/>behind Cloudflare Access]
  end

  CC --> NORM --> W
  AUD --> PG --> W --> Q2 --> SC
  W --> R2
  W --> D1
  W --> Q1 --> SC
  SC --> GW --> AI
  SC --> D1
  DO --> GW
  DO --> D1
  KV --> DO
  KV --> SC
  PG <--> W
  PG <--> DO
  CT -. outcome join (pd_ct_id) .-> D1
  D1 -- publish policy --> KV
  Q3 --> DO

Data flow in one sentence: transcripts (historical or freshly transcribed) are stitched and pseudonymised in code, scored utterance-by-utterance by the same step() engine in batch or live, raw answers land in D1, Jev-tagged moments go to the admin queue, marks become the next policy version, which the eval harness gates before KV publishes it to the replay Durable Object.

4. Components on Cloudflare

Worker api. Stateless HTTP: /ingest (batched inserts from the Node ingest script), /calls, /policy, /moments, /marks, /eval/run, /upload (audio to R2, enqueue STT), and the WebSocket upgrade that routes to a CallSession DO by session id. Also the Queue producer. Why: it is the only place with all bindings. Limits: 100 MB request body on Free/Pro (stt §3.3) bounds a single audio upload at roughly 60-90 min of mp3; D1 100 bound params per statement means ≤12 utterance rows per insert statement, batched with batch() (corpus §5). Existing Worker jev-lab (/home/stevan/dev/jev/src/index.ts, wrangler.jsonc with ai binding) is the seed.

Durable Object CallSession. One per replay or live session. Holds the rolling window, persisted facts, objection state, EMA'd scores, the last N raw answers, the WebSocket to the dashboard, and the replay timer. SQLite-backed so a reconnect resumes. Why a DO: single writer, strong consistency, holds the socket; the reference's in-memory CallSession maps 1:1 (dissection §7a, §10c). Limits: soft 1,000 req/s per object and 32 MiB received WS messages (corpus §5) are irrelevant at one utterance per 2-5 s. env.AI.run has no timeout, so the DO races it against a 10 s deadline and treats a timeout as "unknown, hold previous state" (guide §4.8).

Queue consumer scorer. Consumes score-calls (one message per call, max_batch_size: 1, concurrency ≤ 10 until the rate limit is measured). Processes utterances sequentially because state feeds back (known_facts, stage_history, objection). Idempotent: skips (call_id, i, rubric_hash) rows already present. Why a Queue and not Workflows: the unit of work is one call (~70 Jev calls, ~35-60 s wall-clock, negligible CPU), retries per call are cheap, and the Queue gives concurrency control. Workflows are reserved for the multi-step STT pipeline where durable steps matter. Limits: consumer CPU time default 30 s is CPU, not wall-clock; the scorer is I/O-bound. ASSUMPTION that a 70-request sequential consumer invocation stays under the CPU limit; measure in M1 and raise limits.cpu_ms if needed.

D1 copilot. Source of truth for everything queryable: calls, utterances (pseudonymised), raw answers, derived steps, moments, marks, labels, outcomes, policy versions, eval runs, audit log. Why: cross-call SQL (rankings, per-rep stats, label joins, calibration) is the whole point of the curation loop. Size: 3,121 calls × ~70 utterances × ~3 KB answers ≈ 650 MB, inside the 10 GB limit; 2 MB row cap is far above 3 KB (corpus §5). FTS5 on utterances.text for admin search.

R2 copilot-raw. Raw audio, raw Aircall transcript JSON, raw nova-3 JSON, un-redacted normalised transcripts, eval-run artefacts, golden-set snapshots. Private, lifecycle rule for retention (question for Stevan, §13). Why: blobs with PII do not belong in a query store; write-once; versionable by key. Key scheme raw/{call_id}/{artefact}, eval/{run_id}/…, golden/{policy_version}.json.

KV. policy:v{n} (immutable bundle: question banks, playbook, weights, thresholds, stitch/redact versions) and policy:current (pointer). Read once per session by the DO and once per consumer invocation. Why: read-hot, tiny, global. Limits: eventual consistency up to 60 s on the pointer is fine because bundles are version-addressed; a session pins the version it started with (corpus §5).

Queues. score-calls, stt-jobs, rewrite-jobs. Why: throttling against unknown Workers AI rate limits, idempotent retries, and keeping the rewrite path off the DO's critical path. Dead-letter queue for calls that fail three times (usually an over-budget state; see §6).

Pages ct-copilot. Static SPA (dashboard + admin console), behind Cloudflare Access. Why Pages: Stevan's delivery decision; no server-side code needed because the Worker holds the API. Pages Functions cannot declare an AI binding in wrangler config (guide §4.2), another reason the API is a Worker.

Workers AI models. typesafe/jev via the binding with { gateway: { id } } as third argument; verified ~800 ms including cold start via binding, 400-550 ms via REST (task brief). M1 uses the binding; if per-request cf-aig-* controls (skip-cache, collect-log false, metadata) turn out not to be exposed on the binding, the DO switches to REST with fetch and the account token, which is what scripts/jev.mjs already does. @cf/deepgram/nova-3 via REST with binary body and verified query parameters (diarize, utterances, punctuate, smart_format, language=en-GB, numerals) at ~473 neurons/min. Whisper is not used: no speaker field (stt §3.1). Response unwrapping: walk .result until an object with answers appears (guide §4.7).

AI Gateway jev-copilot. Request/response logging disabled before any client text is sent (guide §4.3, §4.11); spend-limit rule; cf-aig-metadata {call_id, policy_version, mode} for correlation where the route allows it. Unified Billing credits with 5% purchase fee; 402 code 2021 on empty balance must be surfaced as an operator alert, not retried (guide §4.6, §4.8).

Not used in M1: Vectorize (tag filtering and FTS5 are enough for <1,000 lines; corpus §4a), Workflows (M2 STT pipeline), Realtime SFU (M3).

5. Data model

Every policy artefact is immutable and versioned; every derived number is recomputable from answers; every rep-facing line has a provenance chain.

-- corpus
CREATE TABLE calls(call_id TEXT PRIMARY KEY, source TEXT, scenario TEXT, scenario_source TEXT, -- 'call-coach'|'jev'|'admin'
  scenario_conf REAL, rep_id TEXT, pd_ct_id TEXT, client_ref TEXT, recorded_at TEXT, direction TEXT,
  engine TEXT, stitch_version INTEGER, redact_version INTEGER, n_utterances INTEGER, duration_s REAL,
  raw_r2_key TEXT, coach_json TEXT);                                   -- coach_json = original call-coach output (weak labels)
CREATE TABLE utterances(call_id TEXT, i INTEGER, t REAL, t_end REAL, speaker TEXT, text TEXT, words INTEGER,
  fragment_ids TEXT, backchannels INTEGER, pii_json TEXT, PRIMARY KEY(call_id, i));
CREATE VIRTUAL TABLE utterances_fts USING fts5(text, content='utterances');

-- Jev output, immutable per rubric
CREATE TABLE answers(call_id TEXT, i INTEGER, rubric_hash TEXT, model TEXT, answers_json TEXT,
  input_tokens INTEGER, output_tokens INTEGER, latency_ms INTEGER, run_id TEXT, created_at TEXT,
  PRIMARY KEY(call_id, i, rubric_hash));
CREATE TABLE episode_answers(episode_id TEXT PRIMARY KEY, call_id TEXT, start_i INTEGER, end_i INTEGER,
  topic TEXT, rubric_hash TEXT, model TEXT, answers_json TEXT);

-- derived, recomputable (policy_version → weights/thresholds)
CREATE TABLE steps(call_id TEXT, i INTEGER, policy_version INTEGER, features_json TEXT, hero_json TEXT,
  stage TEXT, move_id TEXT, gated INTEGER, PRIMARY KEY(call_id, i, policy_version));
CREATE TABLE call_scores(call_id TEXT, policy_version INTEGER, checklist REAL, risk_flags INTEGER,
  health REAL, resolution TEXT, features_json TEXT, PRIMARY KEY(call_id, policy_version));

-- curation
CREATE TABLE moments(moment_id TEXT PRIMARY KEY, call_id TEXT, scenario TEXT, kind TEXT, -- concern|must_say|risk|next_step|trainer_pick|uncertain
  start_i INTEGER, end_i INTEGER, topic TEXT, jev_json TEXT, policy_version INTEGER,
  priority REAL, review_state TEXT DEFAULT 'queued');
CREATE TABLE marks(mark_id TEXT PRIMARY KEY, moment_id TEXT, verdict TEXT, -- model|acceptable|avoid
  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 outcomes(call_id TEXT PRIMARY KEY, pd_ct_id TEXT, activated_at TEXT, first_settled_trade_at TEXT,
  traded_within_30d INTEGER, traded_again_within_90d INTEGER, revenue_gbp_90d REAL, joined_at TEXT, join_version INTEGER);

-- policy (immutable rows; status draft|candidate|published|retired)
CREATE TABLE question_bank_versions(version INTEGER PRIMARY KEY, scenario TEXT, rubric_hash TEXT, questions_json TEXT,
  created_by TEXT, created_at TEXT, note TEXT);
CREATE TABLE playbook_versions(version INTEGER PRIMARY KEY, scenario TEXT, playbook_json TEXT, created_by TEXT, created_at TEXT);
CREATE TABLE playbook_lines(line_id TEXT PRIMARY KEY, scenario TEXT, move_id TEXT, text TEXT, source_moment_id TEXT,
  status TEXT, approved_by TEXT, approved_at TEXT, retired_at TEXT);
CREATE TABLE policy_versions(version INTEGER PRIMARY KEY, scenario TEXT, question_bank_version INTEGER,
  playbook_version INTEGER, weights_json TEXT, thresholds_json TEXT, stitch_version INTEGER, redact_version INTEGER,
  status TEXT, eval_run_id TEXT, published_by TEXT, published_at TEXT);
CREATE TABLE eval_runs(run_id TEXT PRIMARY KEY, policy_version INTEGER, model TEXT, l1_pass REAL, l2_ordering_ok INTEGER,
  l3_spearman REAL, l5_max_std REAL, tokens_p95 INTEGER, latency_p95_ms INTEGER, cost_usd REAL, r2_key TEXT, created_at TEXT);

-- ops
CREATE TABLE sessions(session_id TEXT PRIMARY KEY, call_id TEXT, mode TEXT, policy_version INTEGER, started_at TEXT,
  ended_at TEXT, timeline_r2_key TEXT);
CREATE TABLE audit_log(id INTEGER PRIMARY KEY, actor TEXT, action TEXT, target TEXT, before_json TEXT, after_json TEXT, at TEXT);

Versioning rules:

6. Per-utterance decision loop

6.1 Utterance stitching (code, versioned stitch_version)

Aircall fragments overlap and split mid-sentence (see [ 307.6] REP: Yeah. that you have But a we're secondary. in /home/stevan/dev/jev/data/samples/onboarding-strong-3339895706.txt). call_turns.data already carries members, stitched_chain_len, backchannels_in_turn from the call-coach pipeline (/home/stevan/dev/jev/data/call-coach/call_turns.jsonl.gz), but the result is still choppy. Second pass, deterministic:

// stitch.ts  (v1) — pure, unit-tested on the four samples in data/samples/
export function stitch(frags: Fragment[], cfg = STITCH_V1): Utterance[] {
  const out: Utterance[] = [];
  for (const f of sortBy(frags, 'start')) {
    const prev = out.at(-1);
    const backchannel = f.words <= cfg.backchannelMaxWords && cfg.backchannelLexicon.has(norm(f.text));
    if (prev && prev.speaker !== f.speaker && backchannel && f.start < prev.t_end + cfg.backchannelGapS) {
      prev.backchannels += 1; continue;                 // "Mhmm." inside the other party's chain: count it, do not break the chain
    }
    const gap = prev ? f.start - prev.t_end : Infinity;
    const continues = prev && prev.speaker === f.speaker &&
      (gap < cfg.mergeGapS || (!endsSentence(prev.text) && gap < cfg.mergeGapUnfinishedS));
    if (continues) { prev.text = joinText(prev.text, f.text); prev.t_end = Math.max(prev.t_end, f.end); prev.fragment_ids.push(f.id); }
    else out.push(fromFragment(f));
  }
  return out.flatMap(u => u.words > cfg.maxWords ? splitAtSentences(u, cfg.maxWords) : [u]);
}
export const STITCH_V1 = { mergeGapS: 1.0, mergeGapUnfinishedS: 2.5, backchannelMaxWords: 2,
  backchannelGapS: 1.5, maxWords: 120, backchannelLexicon: new Set(['yeah','mhmm','okay','ok','right','sure','yes','mm','uh huh']) };

Thresholds are ASSUMPTION, tuned by eye on 20 calls in M1 (corpus §1c). Overlapping garbage ("Yeah. that you have But a we're secondary.") is not repairable in code; it stays, and the eval harness measures whether Jev is robust to it (§9, L5). If not, M3 re-transcribes with nova-3 from Aircall audio (reference/call-coach/scripts/download_call_audio.py).

Speakers: internal → rep, external → client for Aircall; for nova-3 see §7. A third label unknown masks speaker-specific nouls to 0 rather than mislabelling (dissection §10b.7).

6.2 State shape and token budget

Mirrors the reference (dissection §1) with CT pre_call fields (brief §4.2). Only fields a question names are sent.

interface JevState {
  call_facts: {
    scenario: 'onboarding' | 'customer_success';
    client_type?: 'personal' | 'corporate';
    minute: number;                       // code
    talk_ratio_rep: number;               // code, whole call
    stage_history: string[];              // last 6 distinct, code
    known_facts: string[];                // persisted *_known nouls
    checklist_done: string[];             // persisted said_* nouls
    objection: { open: boolean; type?: string };
    pre_call?: { purpose?: string; sell_currency?: string; buy_currency?: string; docs_status?: string; activated?: boolean };
  };
  recent_transcript: { t: string; speaker: 'rep'|'client'|'unknown'; text: string }[];   // last 12 utterances
  latest_utterance: { t: string; speaker: string; text: string };
}

Budget (≈ 4 chars/token, guide §2.5):

Part Estimate Note
Overhead ~300 guide §4.10
call_facts + pre_call ~200
recent_transcript (12 × ~35 tok) + latest_utterance ~500 stitched Aircall turns average ~25 words; a 120-word cap bounds the worst case at ~2k
Shared questions (21) ~2,000 brief §4.3
Scenario questions (onboarding 24 / CS 15) ~2,300 / ~1,500 brief §4.4, §4.5
stage, objection_type, next_move Choices ~1,600 10 + 9 + 14 options with what/not_for
phrasing::<move> × 14 × 3 lines (live only) ~1,600 dropped in batch mode
Total live / batch ~8.5k / ~6.9k hard assert at 16k; 32k limit (guide §8)

assertBudget(state, questions) throws before the request; an over-budget utterance is logged, the request is retried once with an 8-turn window, then the utterance is marked skipped_budget. A 4xx from an oversized request is a hard failure, not a retry (guide §2.5).

6.3 Question-bank structure

Per scenario, compiled from question_bank_versions and the playbook:

Group Type Consumers Source
Shared client-turn nouls: client_objecting, client_accepts, client_disengaging, client_confused, client_asked_about_{rate,safety,timing,fees,documents}, client_mentioned_alternative_provider, client_mentioned_deadline, client_ready_to_book, next_step_agreed noul, masked to 0 on rep turns objection state, signals, moments brief §4.3
Shared rep-turn nouls: rep_asked_open_question, rep_explaining_at_length, rep_proposed_next_step, rep_made_rate_prediction, rep_made_guarantee noul, masked on client turns risk flags, health brief §4.3
Shared scores: engagement, urgency, trust (3 levels) score health composite brief §4.3
Onboarding facts: purpose_known, pair_known, timing_known, frequency_known, funding_source_known, funding_from_third_party, beneficiary_known, docs_status_known, current_provider_known, decision_maker_known, jurisdiction_concern window noul, persist ≥ 0.70 checklist context, known_facts feedback brief §4.4
Onboarding must-say: said_who_holds_funds, said_rate_transparency, said_quote_lifetime, said_settlement_timing, said_fund_from_own_account, said_booking_is_binding, said_minimum, said_docs_needed, said_payment_reason, said_rm_contact window noul, lock ≥ 0.70 on rep turns only hero: checklist completeness brief §2.3, §4.4
CS: issue_reported, issue_resolved_or_owned, upcoming_payment_known, no_upcoming_need, client_wants_to_wait_for_rate, client_asked_about_{forwards,alerts_or_orders,recurring,access}, rep_offered_tool, rep_checked_status_with_specifics, said_forward_deposit_and_liability noul hero: resolution; CS checklist (conditional) brief §4.5
onboarding_stage (10) / cs_stage (9) choice stage strip, stage priors brief §4.4/§4.5
onboarding_objection_type (9 incl none) / cs_objection_type (8) choice, consulted only when client_objecting ≥ 0.60 objection card, episodes brief §4.4/§4.5; dissection §4b
next_move choice over the code-shortlisted moves (≤ 8) next-move card corpus §4a option B
phrasing::<move> choice over the move's approved lines, only for shortlisted moves, live only "say" line dissection §2f

Rules applied (guide §7, jaggedness): one condition per noul, HIGH = yes, true/false describe the same boundary with examples, backticked state paths, no numbers or dates asked of Jev, a none option on every Choice with an absolute noul beside it. The rep_made_rate_prediction / rep_made_guarantee nouls are the CT-specific promise check the decision asks for; they also run on rewrite candidates (§6.6).

Must-say lock only on rep turns: a client paraphrase ("so you never hold my money?") reads the same window and would otherwise tick said_who_holds_funds. ASSUMPTION that this is a real failure mode; L1 includes a negative case for it.

6.4 The step() engine (code owns every number)

export function step(s: SessionState, u: Utterance, a: Answers, p: Policy): { s: SessionState; snap: Snapshot } {
  const f = extractFeatures(a, u.speaker, p);                 // speaker masking, score normalisation by (levels-1)
  s = persistFacts(s, f, u, p.thresholds.persist /*0.70*/);   // *_known on any turn, said_* on rep turns only
  s = updateObjection(s, f, a, u, p);                          // open ≥0.60 client turn; clear on accept ≥0.60 && disengaging <0.60;
                                                               // expiry 8 turns since LAST re-raise (fixes dissection §4b); list of open concerns, newest shown
  s = updateStage(s, a.stage, p);                              // EMA 0.6 over option probabilities; switch only if new > current + 0.05 for 2 turns
  const hero = p.scenario === 'onboarding' ? onboardingHero(s, f, p) : csHero(s, f, p);
  const risk = riskFlags(s, f, p);                             // rep_made_guarantee ≥0.60, rep_made_rate_prediction ≥0.60,
                                                               // funding_from_third_party ≥0.70, booking talk while said_booking_is_binding unlocked
  const move = pickMove(s, a, p);                              // shortlist in code, gate + hysteresis below
  s = pushWindow(s, u, a, f);                                   // rolling 12, store raw answers on the step
  return { s, snap: { hero, risk, move, stage: s.stage, checklist: checklistView(s, f, p), signals: f, objection: s.objection } };
}

Hero metrics:

Gates and anti-flicker (from dissection §5a, prior-art §2.1 decide.js, prior-art §8.4):

Decision Gate Hysteresis
Show next-move card next_move.confidence ≥ 0.35 and max(prob) ≥ 0.30, else "listening… (leaning X)" challenger replaces current only if it leads by ≥ 0.12, or leads for 2 consecutive turns; 3-turn cooldown after a card is shown; rep "done" hides that move for 3 minutes
Highlight a line phrasing::move.confidence ≥ 0.30 none; lines are stable text
Objection card client_objecting ≥ 0.60; type from Choice only if confidence ≥ 0.40, else parent bucket ("money" for rate/fees, "process" for documents/funding) per the classification-using-confidence cookbook (prior-art §4.4) clears on accept, or 8 turns after last re-raise
Lock fact / tick checklist noul ≥ 0.70 (rep turns for said_*) never unlocks in a session
Risk flag noul ≥ 0.60 latched; admin can mark false positive → label
Stage strip EMA'd probabilities, switch margin 0.05, 2-turn confirm

Hard business rules run in code, not in not_for text (dissection §5b): pickMove removes moves whose must_say is already locked, removes agree_next_step while a concern is open, forces check_serviceability_first to the top when jurisdiction_concern ≥ 0.60, and restricts the shortlist to the open concern's topic. Jev picks within the shortlist; the UI shows "rule: X hidden because Y" when a rule overrode Jev's top choice.

6.5 Batch mode differences

Same step(); differences are configuration: no phrasing::* questions, no rewrites, no WebSocket, uid field in state omitted (caching irrelevant with logging off; cf-aig-skip-cache if the gateway ever caches). After the call, episode cutting runs (concern open → accept/proceed, cap 8 turns) and one request per episode asks resolved, response_specific, response_compliant, handling_quality (4-level score), exemplar_candidate (corpus §2 Layer 2). Then moments are emitted (§8.2).

6.6 Tailored rewrite path (off critical path, droppable, M2, flag per scenario)

Trigger (from dissection §6a): move changed, or new fact locked, or same move for 8 turns; cooldown 2 turns; skipped for clarify_simply, ask_about_the_transfer, and any move whose must_say covers safeguarding, binding, or fees (ASSUMPTION: those stay verbatim-approved). The DO enqueues rewrite-jobs; a consumer calls a small generative model (Haiku 4.5 through AI Gateway, ~900 in / ~30 out tokens) with the approved lines as tone anchors and the last 6 pseudonymised turns, cleans the line in code, then asks Jev four nouls on {candidate_line, move, recent_transcript, known_facts}: invents_fact (drop ≥ 0.50), on_move (drop < 0.50), makes_promise_or_guarantee (drop ≥ 0.30), predicts_rate (drop ≥ 0.30). Verification unavailable → drop, the opposite of the reference's pass-through (dissection §6d), because a regulated line must never reach a rep unverified. Stale if the move changed. Delivered as a separate phrasing WS message; the dashboard shows dropped candidates struck through with the reason so Stevan can audit the filter. Every candidate, verdict and noul value is written to audit_log.

7. Ingestion & STT

Historical (no STT). Node script scripts/ingest-call-coach.mjs reads the three jsonl.gz exports (unescape backslashes before JSON.parse, per the task brief), filters to category ∈ {Onboarding, Customer Service}, duration ≥ 180 s, ≥ 20 turns (3,121 calls), maps role internal|external → rep|client, runs stitch() and redact() (§10), writes the raw normalised transcript to R2 raw/{call_id}/aircall.json and pseudonymised utterances plus the original coach output (coach_json, kept as weak labels) to D1 via /ingest in batches of 12 rows per statement. scenario comes from call_records.category with scenario_source='call-coach' (LLM-classified, so treated as a label to verify, not truth; M2 adds a Jev scenario Choice and flags disagreements, corpus §1e). pd_ct_id is stored on calls for the outcome join.

Uploaded audio (nova-3). Pages upload → Worker streams the file to R2 raw/{call_id}/audio.{ext} → stt-jobs message → consumer POSTs the binary body to /ai/run for @cf/deepgram/nova-3 with the verified query string plus keyterm for FX vocabulary and mip_opt_out=true (stt §5.1; parameter effect on price unverified) → raw JSON to R2 → results.utterances[] mapped to {t: start, t_end: end, speaker: N, text: transcript, confidence} → speaker-role assignment → stitch() → redact() → D1. Body-size ceiling on the Cloudflare proxy is unverified beyond 81 s of audio (stt §5.1 test 2); M1 measures with a 30-40 min file and, if it fails, chunks at silence boundaries with timestamp offsets. If recordings are dual-channel, multichannel=true replaces diarisation (stt §3.3; question for Stevan).

Speaker → rep/client. Code heuristic first: the speaker whose first 60 s contain "Currency Transfer" / "calling from" / the rep's name is rep; on outbound calls the first speaker after the greeting is usually the rep (the strong sample shows the client answering first, so this alone is insufficient). Then one Jev Choice per diarised speaker over {rep, client, other} on that speaker's first 8 turns; accept at confidence ≥ 0.80, else unknown and an admin toggle on the replay page (corpus §1b). The scenario for an upload is chosen by the uploader in M1; M2 adds the Jev suggestion.

Replay pacing. The DO sleeps max(0.15, min(6, gap)/speed) between utterances and fires each at its t_end, exactly as an STT finalises a segment (dissection §7d, stt §1).

8. Dashboard and admin console

8.1 Dashboard (Pages, one page per session)

Panel Shows Source
Hero Onboarding: checklist x/y with named items (locked / uncertain / unsaid / n/a) and red risk flags. CS: resolution state chip + health bar with sparkline snap.hero, snap.risk
Stage strip EMA'd stage with history chips snap.stage
Transcript stitched turns, speaker colour, backchannel counts, talk-share bar, rep-monologue warning session window
Concern card open concern type (or parent bucket), since when, approved responses for that topic objection state + playbook
Next move card title, what, up to 3 approved lines with the highlighted one; "listening…" when gated; rule-override note; tailored line (M2) struck through if dropped snap.move, phrasing
Signals grid every weighted signal with dot/lock, score level snap.signals
Debug drawer last Jev request/response, model id, tokens, latency, policy version, model-drift flag DO

WebSocket protocol (superset of dissection §7c so the reference UI logic ports):

client → DO:  {type:'start_replay', call_id, speed}  | {type:'pause'} | {type:'resume'} | {type:'seek', i}
              {type:'utterance', speaker, text, t?}   // type/live modes
              {type:'set_weights', weights, thresholds}  → 'recomputed' with zero Jev calls (from stored answers)
              {type:'move_done', move_id} | {type:'flag_false_positive', kind, i}   // writes labels
DO → client:  {type:'hello', policy_version, model_expected, playbook, weights, thresholds, calls[]}
              {type:'reset', call}
              {type:'update', i, t, speaker, text, hero, risk, stage, checklist, objection, move, signals, jev:{model,latency_ms,input_tokens}, replay:{i,total}}
              {type:'phrasing', i, move_id, text|null, rejected, reason, verification:{invents_fact,on_move,makes_promise_or_guarantee,predicts_rate}}
              {type:'recomputed', timeline[]} | {type:'replay_done', summary} | {type:'error', code, message}

8.2 Admin console (Pages, Cloudflare Access, Stevan only in M1)

Moments are emitted by the batch scorer and the DO at session end:

Kind Emitted when What the admin sees
concern episode cut (§6.5) client turn that opened it, rep turns, resolution turn, handling_quality, resolved, exemplar_candidate
must_say a said_* noul first crosses 0.70 on a rep turn the rep turn ± 2, which checklist item, noul value
risk rep_made_guarantee / rep_made_rate_prediction ≥ 0.60 or third-party funding the rep turn, noul value
next_step next_step_agreed ≥ 0.70 the exchange
trainer_pick exemplar_candidate ≥ 0.70 the episode
uncertain any gate value in 0.30-0.70 the turn; most valuable for criteria repair (corpus §2b)
seed from call-coach coach_json.quotes[].turn_idx and risks the original coach's pick, as a weak hint

Queue ordering by information value (corpus §3c): uncertain first, then high-composite unlabelled calls, then bottom-composite, then disagreements between exemplar_candidate and the atomic composite.

Actions and their effects:

Action Writes Effect Jev cost
Mark moment model / acceptable / avoid, optional note, tick "use as example" marks model + rep turn → candidate playbook_lines row (verbatim, PII tokens cleaned, editable) and candidate criterion examples; avoid → not_for / false example candidate and an L1 negative case; all → gold labels for L1/L2 0
Correct a Jev answer (type, stage, speaker, checklist tick) labels gold label; ≥ N corrections on one question flags "rewrite this criterion" 0
Approve a playbook line (text, move, approved_by) playbook_lines included in the next playbook version 0
Edit weights / thresholds new policy_versions (draft) corpus re-scored from answers; before/after diff of rankings and checklist rates 0
Edit question wording / criteria / examples new question_bank_versions (draft), new rubric_hash harness re-scores the labelled set only ~100 labelled calls ≈ $2.5
Promote policy_versions.status → candidate → published, KV policy:v{n} + pointer, audit_log live sessions start on the new version; backfill of the corpus under the new rubric_hash runs in the background full corpus ≈ $75

Question wording changes are drafted by an LLM from Stevan's intent in the house style and gated by the harness (corpus §3b option B); Stevan never edits a live prompt directly.

9. Evaluation harness and promotion gate

Golden set = snapshot of (call_id, i, gold_json) from labels and (moment_id, verdict) from marks, exported to R2 golden/{version}.json with a small copy committed to the repo so CI runs without D1.

Layer Gold Check Gate (M1 → M2)
L1 labelled utterances labels threshold checks per case (client_objecting >= 0.60, objection_type in {rate}, said_who_holds_funds < 0.40 on the client-paraphrase negative) in the reference's report format (dissection §9b) ≥ 90% pass; no case regressed by > 0.10 vs the published version
L2 marked episodes marks `mean(handling_quality model) > acceptable > avoidwith margin ≥ 0.3;exemplar_candidate ≥ 0.70on ≥ 80% ofmodel`
L3 call ranking outcomes + coach_json.call_value_score (weak) Spearman(health or checklist, admin rank) ≥ 0.6; AUC(checklist completeness → traded_within_30d) reported; correlation with the old coach's dims reported, not gated M2 soft gate
L4 call shapes the 4 exemplars in data/samples/ + 2 more per scenario onboarding-strong ends with checklist ≥ 6/10 and 0 risk flags; onboarding-weak ends ≤ 3/10 with client_disengaging persisted and trust level 0 at some point; CS-strong ends resolved; CS-weak never reaches owned M1 hard gate
L5 stability L1 × 5 with fresh uid per-question std ≤ 0.03; list of threshold-crossing flips M1 report, M2 gate
L6 budget every run tokens/request p95 ≤ 12k, latency p95 ≤ 1.5 s via binding, cost/call ≤ $0.05, model == model_expected M1 hard gate

Calibration: for each noul with ≥ 30 labels, a reliability plot (predicted vs observed) and ECE; thresholds are then chosen per question at a target precision (risk flags: precision ≥ 0.9; checklist ticks: balanced), replacing the reference's uniform 0.60/0.70. Noul-first design because the independent test found nouls best calibrated (ECE 0.012 vs 0.086 Choice, 0.254 Score; prior-art §5), so hero metrics never depend on a Score magnitude.

Deriving weights from outcomes (M2, once ≥ 300 joined calls): logistic regression of traded_within_30d (onboarding) / traded_again_within_90d (CS) on the stored per-call features; coefficients become the health weights after Stevan's review; the autoresearch loop (cookbooks__autoresearch_feature_discovery.md, prior-art §4.4) proposes new questions screened by nouls before they are paid for. Rep quality and opportunity quality stay separate scores (semarize, prior-art §2.9).

Promotion: draft → harness run → diff view (flipped cases, moved exemplars, ranking deltas) → publish → background backfill. Model drift triggers the same run with model as the variable. The harness runs from a Worker endpoint (admin "Promote") and from CI (scripts/eval.mjs against /ai/run, cost < $1 per run for ~150 cases).

10. Compliance and PII

Redaction before Jev, in code, versioned (redact_version). Applied at ingest to every utterance before anything reaches D1, KV, the DO, Jev, or the UI: IBAN (mod-97), UK sort code + account number, card numbers (Luhn), phone numbers, emails, postcodes, dates of birth, passport-number patterns, spoken digit runs of ≥ 6 digits ("four two one one oh nine"), and names: rep names from call_records.agent → [REP], client names from pd_first_name/pd_last_name → [CLIENT], plus a capitalised-token-after-"Hi|Hello|Thanks" heuristic. Replacements are typed tokens ([IBAN], [PHONE], [CLIENT]) so questions still read naturally; amounts and currencies are kept because pair_known / amount context depends on them. The un-redacted transcript lives only in R2 raw/ with restricted access. M2 adds the Jev pii_present sweep on the pseudonymised text and routes ≥ 0.50 to a redaction queue (corpus §1d).

Data-processing notes. (a) Jev on Workers AI is a third-party model; whether TypeSafe's DPA / no-training / ZDR commitments cover traffic proxied through Cloudflare is unresolved (guide §4.11) — nothing client-derived is sent until Stevan confirms, and M1 can run on the four already-exported samples plus redacted text only. (b) AI Gateway logging stores request and response bodies regardless of ZDR; the jev-copilot gateway has logging disabled and REST calls send cf-aig-collect-log: false (guide §4.3). (c) Deepgram model-improvement opt-out via mip_opt_out=true; whether the Cloudflare proxy honours it is unverified (stt §5.1). (d) Lawful basis for processing recordings for QA/training and retention periods: question for Stevan (corpus §8). (e) Recording disclosure at the top of calls: question for Stevan (brief §2.3).

Approved-text policy. A line reaches a rep only if playbook_lines.status='approved' with approved_by, or it passed all four verification nouls in §6.6 within the last 30 s and the move is still current. wrangler dev bills real usage and would send real state (guide §4.2); unit tests stub env.AI.run with recorded fixtures.

Audit log. Every policy change, mark, approval, promotion, rewrite candidate and verdict, and every session start/end with policy_version and model is an audit_log row. Per-request cf-aig-metadata and the REST run id are stored on answers.run_id where the route exposes them (guide §4.7).

11. Milestones

M1: replay PoC on corpus-derived policy (target 1-2 weeks of agent-driven work)

# Deliverable Acceptance criteria
1 Repo scaffold: Worker api, DO CallSession, Queue consumer scorer, D1 schema (§5), R2 bucket, KV namespace, Pages project, wrangler config, vitest, CI wrangler deploy succeeds; D1 migrations apply; CI runs unit tests with a stubbed AI binding; no real Jev calls in unit tests
2 stitch() v1 and redact() v1 as pure modules with fixtures from data/samples/ The 4 samples stitch to ≤ 60% of their fragment count with no cross-speaker merges (hand-checked); redaction removes every phone, name from agent/pd_*, and digit run ≥ 6 in the samples; both versions are recorded on calls
3 Ingest script for the three jsonl.gz exports; load 50 Onboarding + 50 Customer Service usable calls (including the 4 samples) 100 calls rows with scenario_source='call-coach', pd_ct_id where present, coach_json stored; raw JSON in R2; utterances_fts queryable
4 Policy v1: question banks (brief §4, edited), playbooks (brief §5, every line status='draft'), weights, thresholds; loaded into D1 and KV assertBudget passes at ≤ 10k tokens live / ≤ 8k batch on the longest window in the 100 calls; one representative request through the binding returns model and answers for every question id
5 Batch scorer: Queue consumer runs step() sequentially per call, stores answers, steps, call_scores, episodes, moments All 100 calls scored under one rubric_hash; re-running is a no-op; cost ≤ $3 total; measured requests/min and the effective rate limit are recorded in the run notes
6 Minimal admin page: moment queue for the 100 calls (ordered per §8.2), transcript span, Jev values, three verdict buttons, "use as example", note; playbook line editor with approved_by Stevan marks ≥ 60 moments in one sitting; ≥ 10 lines lifted from model moments are approved and compiled into playbook v2; policy v2 published to KV
7 Replay: DO + WebSocket + dashboard panels (§8.1) for any ingested call, speed 0.5-20x, pause/seek, set_weights → recomputed with zero Jev calls The onboarding-strong sample replays end-to-end with p95 utterance-to-update ≤ 1.5 s; the checklist, stage, concern and next-move panels update; debug drawer shows model id and tokens
8 Audio upload path: Pages → R2 → stt-jobs → nova-3 REST → speaker assignment (heuristic + Jev Choice, admin toggle) → ingest → replay An uploaded 10-30 min mp3 becomes a replayable call; speaker roles assigned at ≥ 0.80 confidence or flagged; nova-3 neurons per minute logged; body-size ceiling on the proxy recorded
9 Eval harness L1 (≥ 30 labelled utterances from marked moments, 15 per scenario, including the client-paraphrase negative), L4 (the 4 samples), L6; scripts/eval.mjs in CI L1 ≥ 90%; L4 assertions in §9 hold on policy v2; L6 holds; report JSON written to R2 and committed
10 Deployment: Pages canonical URL behind Cloudflare Access; gateway jev-copilot with logging off and a spend limit; runbook Stevan opens the dashboard and admin page from a fresh browser via Access; gateway logging setting screenshotted in the runbook; credit balance delta reconciled against usage for the M1 batch (guide §4.10)

Explicitly out of M1: tailored rewrites, outcome joins, full-corpus scoring, weight editing UI beyond set_weights on the replay page, Jev scenario classification, PII noul sweep, live audio.

M2: curation console and corpus scoring

Score all 3,121 usable calls (≈ $75); outcome join via pd_ct_id against CT broker_accounts.verified_at and trade_bookings (read-only access needed; ct-sql skill conventions); L2/L3/L5 gates and calibration plots; threshold derivation per question; weights from logistic regression; full admin console (ranking diffs, question-draft-with-LLM flow, promotion diff view, rollback); Jev scenario Choice with disagreement flags; PII noul sweep; tailored rewrite path behind a per-scenario flag; nightly harness on model drift; retention lifecycle rules on R2.

M3: live audio

Transport decision (Aircall media stream vs browser softphone vs RealtimeKit; stt §5.3); nova-3 WebSocket from the DO with interim_results, endpointing=300, utterance_end_ms=1000, KeepAlive; act on speech_final; per-leg audio preferred over diarisation; bulk re-transcription of Aircall audio if L5 shows fragment garbage hurts; Vectorize exemplar search; autoresearch weights; rep-facing rollout with rep feedback signals (move_done, thumbs) feeding labels.

12. Cost and latency budget

Prices: Jev $0.042/M input tokens pass-through, output free, +5% on credit purchases (task brief; guide §4.6, §4.10); nova-3 ~473 neurons/min ≈ $0.0052/min (task brief; stt §3.3); Haiku 4.5 $1/$5 per Mtok (dissection §6e). Utterance count per call ASSUMPTION 70 stitched turns (average usable call ≈ 7 min; the 9:47 strong sample has ~130 fragments).

Item Tokens / units Cost Latency
One replayed call, live bank (70 × 8.5k) 595k tok $0.025 (+5% = $0.026) 400-800 ms per utterance via binding; p95 target ≤ 1.5 s incl. DO and WS
Tailored rewrites (M2), ≤ 10 per call 10 × (Haiku ~1k + Jev verify ~0.8k) ≈ $0.010 ~1-2 s, off critical path
Upload STT, 10-min mp3 4,730 neurons ≈ $0.052 (first ~21 min/day free) ~15-30 s for the file (81 s took 1.9 s)
Per call, all-in (M2) ≈ $0.03-0.09
1,000 historical calls, batch bank (70 × 6.9k) 483M tok $20.3 (+5% ≈ $21.3) concurrency 8 at 0.5 s: ≈ 73 min; if the Workers AI Text Generation default of 300 rpm applies: ≈ 3.9 h
Episodes, 1,000 calls × ~4 × 1.5k 6M tok ≈ $0.25 negligible
PII noul sweep, 70k utterances × 400 28M tok ≈ $1.2
Per 1,000 historical calls ≈ $23-25 1-4 h
Full usable corpus (3,121 calls) ≈ $75 4-13 h
Harness run (~150 cases × 5 for L5) ~6M tok ≈ $0.30 minutes
D1/R2/KV/Queues/DO within Workers Paid ($5/mo) at this scale

Reconcile on day one: note the credit balance, run the M1 batch of 100 calls, compare the balance delta to Σ usage.input_tokens × $0.042/M to learn whether output tokens are charged on Cloudflare (guide §4.10).

13. Risks, unknowns, questions for Stevan

Risks and unknowns:

  1. Aircall transcript quality. Overlap garbage may make window nouls noisy. Mitigation: L5 stability on real turns in M1; M3 re-transcription fallback.
  2. Workers AI rate limit for typesafe/jev is undocumented (guide §4.9; corpus §8). Batch wall-clock varies 4×. Measured in M1 deliverable 5.
  3. 32k vs 64k context on the Cloudflare route is unconfirmed; budget asserted at 16k so it does not matter until phrasing Choices grow.
  4. No version pinning on Cloudflare; thresholds tuned on jev-1.13.0 can silently drift. Mitigation: model_expected, drift flag, nightly harness.
  5. Third-party model data terms for pseudonymised client text through Workers AI (guide §4.11). Blocking for anything beyond the four samples until answered.
  6. pd_ct_id coverage is 82% / 64% by category, and the strong onboarding sample has an empty pd_ct_id, so the best calls may be under-joined. Outcome-derived weights will carry selection bias; report join rate per scenario and per rep.
  7. call-coach categories are LLM-assigned. Some "Onboarding" calls will be Biz Dev or CS; M2's Jev scenario Choice and admin confirmation clean this.
  8. Must-say nouls firing on client paraphrase and next_move gating at 0.35 are reference-derived guesses; both are covered by L1 cases and retuned from labels.
  9. Playbook content is draft. Every line in brief §5 carries [VERIFY] items; the copilot is only as compliant as the approval step. M1 line approval by Stevan is a stand-in for whoever owns compliance.
  10. Admin time. The loop needs ~1-2 h/week of Stevan's marking to move; nothing else derives thresholds.
  11. Deepgram diarisation on mono narrowband audio degrades on overlap; unmeasured on CT recordings.
  12. Queue consumer CPU limit with 70 sequential fetches per invocation: ASSUMPTION it fits; fallback is one message per 20 utterances with state carried in the message.

Questions only Stevan can answer:

  1. May pseudonymised transcript text be sent to typesafe/jev through Workers AI, and to Deepgram via Cloudflare, under CT's data-processing obligations? Who signs that off?
  2. Read access to CT production tables for the outcome join (broker_accounts, trade_bookings): direct read replica, a nightly extract, or ct-sql-skill queries run by hand?
  3. Who approves playbook lines and must-say wording: Stevan alone in M1, or a compliance owner with their own role from the start?
  4. Is a recording disclosure required at the top of calls, and what retention period applies to raw audio and transcripts in R2?
  5. Are Aircall recordings mono or dual-channel? (Decides multichannel vs diarize, stt §5.1 test 3.)
  6. Is the call-coach category trustworthy enough to seed M1, or should the 100 M1 calls be hand-picked?
  7. For Customer Success, is the hero "resolution state" alone, or resolution + the numeric health bar?
  8. Which must-say items are applicable per client type (personal vs corporate) and per stream (PFX/CFX/SAR/IL)? The applicable set in §6.4 is code and needs the rule.
  9. Who can open the dashboard and admin console (Access policy): Stevan only, RMs, managers?
  10. Live transport for M3: Aircall media stream, browser softphone, or something else?

14. Sources

7,643 words · Internal working documents. Do not share outside CurrencyTransfer.