Appearance
Post-call extraction nondeterminism — same transcript, divergent scheduledAt
What happened
Running a multi-shop fanout test (chore/service-matcher-fanout-test branch) that replays one captured transcript against six AutoOps tenants, six identical OpenAI extraction calls produced one off-by-154-days outcome. Same input, same prompt, same model — different output.
The fanout's pipeline per shop:
- Pick a real slot via
client.getAvailability. - Render
transcriptTemplatewith{{SLOT_FULL}}etc. for that slot (all six shops landed on the same first-available slot:Thursday, November 12th at 7:00 AMMountain Time). - Run
extractCallDataon the rendered transcript. convertScheduledAtToUtcIso(extracted.scheduledAt, teamTimezone).- Assert the converted UTC matches the slot's UTC.
All six runs used identical rendered transcripts. Five extracted the correct date. One didn't.
The evidence
| Shop | Picked Slot (UTC) | Extracted (naive) | Converted UTC | Round-trip |
|---|---|---|---|---|
| Chatfield | 2026-11-12T14:00:00.000Z | 2026-11-12T07:00:00 | 2026-11-12T14:00:00.000Z | ✅ exact |
| Crestline | 2026-11-12T14:00:00.000Z | 2026-11-12T07:00:00 | 2026-11-12T14:00:00.000Z | ✅ exact |
| South Park | 2026-11-12T14:30:00.000Z | 2026-11-12T07:30:00 | 2026-11-12T14:30:00.000Z | ✅ exact |
| Quebec | 2026-11-12T14:00:00.000Z | 2026-06-11T07:00:00 | 2026-06-11T13:00:00.000Z | ❌ 154 d |
| Platt | 2026-11-12T14:00:00.000Z | 2026-11-12T07:00:00 | 2026-11-12T14:00:00.000Z | ✅ exact |
| Ponderosa | 2026-11-12T14:00:00.000Z | 2026-11-12T07:00:00 | 2026-11-12T14:00:00.000Z | ✅ exact |
Reproduce: apps/web/scripts/service-matcher-fanout/fanout.test.ts on chore/service-matcher-fanout-test, run with AUTOOPS_FANOUT_LIVE=1 AUTOOPS_FANOUT_BOOK=1.
The Quebec extraction returned 2026-06-11T07:00:00 for a transcript that said Thursday, November 12th at 7:00 AM. The output isn't a parse of the slot text — it's an unrelated date. Pure model sampling variance.
Why we care
In production, extractCallData runs once per call (lib/auto-service/extraction.ts:59). If that single call lands on a Quebec-shaped outcome, the post-call workflow books the appointment at the wrong date with no detection mechanism. Customer expects Nov 12; AutoOps records June 11.
Detection in prod today is reactive at best:
- The shop sees an unexpected appointment land on their calendar.
- The customer arrives on a day there's no record of them.
customerNotesretains the agent's spoken confirmation, which would not matchscheduledStartAt— but no system compares the two.
We don't have a per-call sampling distribution, so the 1-in-6 rate from this test isn't a population statistic. It's an existence proof. Worth understanding the failure shape and whether existing safeguards (e.g., convertScheduledAtToUtcIso schema validation) catch enough of it.
Root cause hypothesis
extractCallData calls openai.chat.completions.create({ model: 'gpt-4o', messages, response_format: { type: 'json_object' } }) with no temperature, top_p, or seed parameter. Default sampling applies. Even with strict JSON-mode, the model can produce semantically-divergent date strings for identical inputs — particularly when the source transcript references multiple dates (initial-availability mentions, customer's "two weeks from now" framing, the substituted final-confirmation line).
The current transcript has at least four date-bearing lines:
- Bot offering Wednesday availability for the initial day
- User saying "two weeks from now"
- Bot offering
{{SLOT_DATE}}(rendered as Thursday Nov 12) - Bot confirming
{{SLOT_FULL}}in the final wrap-up
Even when the placeholder lines align, ambient ambiguity in the transcript is enough for sampling to swing the model toward an unrelated date.
Mitigations to discuss
| Option | Tradeoff |
|---|---|
Set temperature: 0 in the extraction OpenAI call | Cheapest. Reduces sampling variance significantly but doesn't eliminate it. Doesn't break anything in callers. |
Cross-check extracted scheduledAt against the in-call autoOpsGetAvailability selection | Strongest. The agent already chose a slot via the in-call tool; that selection is in the call's audit trail. Compare against the post-call extraction; flag/reject on mismatch beyond a threshold. |
| Re-extract on inconsistency | Run extraction twice with temperature: 0. If outputs disagree, fall through to graceful failure + manual review. Doubles the OpenAI cost per call. |
Schema-validate scheduledAt against sensible bounds | Add a Zod check: scheduledAt must be within N days of created_at. A 154-day skew would fail. Cheap, but doesn't catch in-window divergences (e.g., wrong-week-same-month). |
Compare scheduledAt against customerNotes content via second LLM pass | Validates the agent said what we extracted. Expensive, but high signal. |
The most surgical: temperature=0 plus a Zod bound on the extracted scheduledAt relative to the call's created_at. Captures the obvious skews without changing the workflow shape. Cross-check against the in-call tool selection is the architecturally correct answer but requires plumbing the in-call audit trail into the post-call workflow.
What we should mirror in our work
- Any new LLM-driven extraction we add should set
temperature: 0by default. - Any boundary that converts model output to a real-world action (booking, customer creation, etc.) should validate the output's plausibility before committing.
- Round-trip assertions like the one in
fanout.test.tsare cheap and surfaced this. The pattern (test assertsextract(render(slot)) == slot) is reusable for any future extraction path we add.
Related links
- Test that surfaced it:
apps/web/scripts/service-matcher-fanout/fanout.test.tsonchore/service-matcher-fanout-test - Extraction call site:
apps/web/lib/auto-service/extraction.ts:59(extractCallData— note the absence oftemperature) - Timezone conversion utility:
apps/web/lib/workflow/stages/booking-autoops/scheduled-at-conversion.ts:67(convertScheduledAtToUtcIso) - Post-call workflow that consumes extraction output:
apps/web/lib/workflow/stages/booking-autoops/run-booking-autoops.ts