Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@ xray has exactly two write surfaces; both are documented and Valibot-validated a
1. **Control plane (the SDK calls these directly).**
- `POST /v1/conversations` — idempotent upsert of the Conversation spec (turns + per-turn assertions + conversation-level judges) keyed by its content hash. The server computes the hash; the SDK never hashes anything. Same canonical JSON in → same hash → row upsert (last-write-wins on `name`).
- `POST /v1/replays` — eager Replay-row creation (`lifecycle_state='pending'`). Returns `replay_id` so the SDK can propagate it (LiveKit room metadata → OTEL baggage) BEFORE the dev's agent emits its first span.
- `POST /v1/replays/:id/audio` — driver uploads the 48kHz int16 **stereo WAV** (L = user, R = agent, wall-clock-aligned). Flips `lifecycle_state` to `recording_uploaded`.
- `POST /v1/replays/:id/analyze` — enqueues the bunqueue `analyze-replay` job. The server transitions to `lifecycle_state='analyzing'` with `analysis_step='vad'`. **Three-stage chain**: `analyze-replay` (VAD per channel + Whisper transcription per turn + turn_idx backfill on tool_calls/model_usage) → `calculate-metrics` (agent_response_ms, ttft_ms, interrupted) → `evaluate-replay` (runs declared assertions + judges, writes `assertion_results` / `judge_results` / `replay_evaluations`, flips lifecycle to `completed`). Each stage enqueues the next on success; any stage's failure stamps `lifecycle_state='failed'` with a stage-specific `failure_reason`.
- `POST /v1/replays/:id/audio` — driver uploads the 48kHz int16 **stereo WAV** (L = user, R = agent, wall-clock-aligned), plus the `X-Recording-Started-At` header: the wall-clock of audio sample 0, persisted as `replays.recording_started_at` — the sole origin for mapping span timestamps onto the audio timeline. Flips `lifecycle_state` to `recording_uploaded`.
- `POST /v1/replays/:id/analyze` — enqueues the bunqueue `analyze-replay` job. The server transitions to `lifecycle_state='analyzing'` with `analysis_step='vad'`. **Three-stage chain**: `analyze-replay` (VAD per channel + Whisper transcription per turn) → `calculate-metrics` (audio-frame metrics: agent_response_ms, interrupted) → `evaluate-replay` (runs declared assertions + judges, writes `assertion_results` / `judge_results` / `replay_evaluations`, flips lifecycle to `completed`). Each stage enqueues the next on success; any stage's failure stamps `lifecycle_state='failed'` with a stage-specific `failure_reason`. Tool/model spans are attributed to turns at eval/read time by mapping their wall-clock `started_at` onto the audio timeline (origin = `replays.recording_started_at`, see `docs/specs/0001-timeline-clock-alignment.md`) — there is no stored `turn_idx` on those rows and no backfill stage.
- `GET /v1/replays/:id/events` — SSE stream of `state` / `progress` / `evaluation_complete` / `failed` events. The `evaluation_complete` event carries the full `ReplayResult` payload (verdict + per-assertion + per-judge + per-turn metrics) — the SDK returns immediately without a follow-up GET.
- `GET /v1/replays/:id/result` — same `ReplayResult` payload outside the SSE stream for late subscribers / inspector hydration.
- `PATCH /v1/replays/:id` — driver-side failures only (`failure_reason='driver_aborted'` / `audio_missing` / `agent_not_joined`). Lifecycle transitions during the analyze chain are server-owned.

2. **OTLP/HTTP receiver (the dev's agent emits spans).**
- `POST /v1/otlp/v1/traces` — OpenTelemetry traces (JSON + protobuf). **Filters, not gates**: routes spans by the `xray.replay.id` resource attribute and runs each through a vocabulary registry (`src/server/otlp/vocabularies/`: `xray.*`, OTel GenAI semconv `gen_ai.*`, Langfuse). Unknown vocabularies are dropped silently; unknown replay ids are dropped silently. Extracted fields land in `tool_calls` and `model_usage` with `turn_idx=NULL` (the `analyze-replay` job backfills `turn_idx` by matching the span's `started_at` to a `replay_turns.voice_start_ms..voice_end_ms` window). Every accepted span lands in `spans`. `xray.turn` / `xray.stage.*` are accepted as raw spans only. `xray.assertion` and `xray.judge` are no longer recognized — evaluation runs from the declared `Assertion` / `Judge` catalog.
- `POST /v1/otlp/v1/traces` — OpenTelemetry traces (JSON + protobuf). **Filters, not gates**: routes spans by the `xray.replay.id` resource attribute and runs each through a vocabulary registry (`src/server/otlp/vocabularies/`: `xray.*`, OTel GenAI semconv `gen_ai.*`, Langfuse). Unknown vocabularies are dropped silently; unknown replay ids are dropped silently. Extracted fields land in `tool_calls` and `model_usage` with no stored turn association — turn membership is derived at eval/read time from each row's wall-clock `started_at` against the audio timeline (origin = `replays.recording_started_at`, sent by the driver as the `X-Recording-Started-At` header on the audio upload). Model TTFT, when the agent's instrumentation emits `gen_ai.response.time_to_first_chunk`, lands on `model_usage.ttft_ms`. Every accepted span lands in `spans`. `xray.turn` / `xray.stage.*` are accepted as raw spans only. `xray.assertion` and `xray.judge` are no longer recognized — evaluation runs from the declared `Assertion` / `Judge` catalog.

The two paths are coupled by trust: the OTLP receiver doesn't create Conversation or Replay rows, ever. The trust boundary is the SDK's POST. The analyze chain adds three "internal" write paths — the embedded bunqueue workers writing `replay_turns` + `speech_segments` + `turn_transcripts` + `replay_metrics` + `assertion_results` + `judge_results` + `replay_evaluations` after reading the uploaded WAV + the declared spec.

Expand Down
24 changes: 15 additions & 9 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,10 @@ flowchart TB
subgraph CP["Control plane — Valibot-validated, idempotent"]
CP1["POST /v1/conversations<br/><i>multipart spec + audio bytes<br/>server hashes canonical turns → conversation_hash<br/>upsert by hash (last-write-wins on name)</i>"]
CP2["POST /v1/replays<br/><i>eager row create — lifecycle_state='pending'<br/>returns replay_id</i>"]
CP3["POST /v1/replays/:id/audio<br/><i>stereo WAV → XRAY_AUDIO_ROOT<br/>lifecycle_state='recording_uploaded'</i>"]
CP3["POST /v1/replays/:id/audio<br/><i>stereo WAV → XRAY_AUDIO_ROOT<br/>X-Recording-Started-At → replays.recording_started_at<br/>lifecycle_state='recording_uploaded'</i>"]
CP4["POST /v1/replays/:id/analyze<br/><i>enqueue bunqueue job<br/>lifecycle_state='analyzing'<br/>analysis_step='vad'</i>"]
CP5["PATCH /v1/replays/:id<br/><i>lifecycle_state / failure_reason / finished_at</i>"]
CP6["analyze-chain workers<br/><i>analyze-replay: VAD + Whisper → speech_segments + replay_turns + turn_transcripts<br/>calculate-metrics: agent_response_ms + ttft + interrupted → replay_metrics<br/>evaluate-replay: assertions + judges → assertion_results + judge_results + replay_evaluations<br/>lifecycle_state='completed' on chain success</i>"]
CP6["analyze-chain workers<br/><i>analyze-replay: VAD + Whisper → speech_segments + replay_turns + turn_transcripts<br/>calculate-metrics: agent_response_ms + interrupted → replay_metrics<br/>evaluate-replay: assertions + judges → assertion_results + judge_results + replay_evaluations<br/>lifecycle_state='completed' on chain success</i>"]
end
subgraph RX["OTLP receiver — filters, not gates"]
RX1["POST /v1/otlp/v1/traces<br/>JSON or protobuf<br/><br/>Routes by xray.replay.id resource attr.<br/>Unknown replay_id → silent drop.<br/>Unknown vocabulary → silent drop.<br/><br/>Vocabularies in src/server/otlp/vocabularies/:<br/>• xray.ts (xray.* recognized, raw spans only)<br/>• gen-ai-semconv.ts (gen_ai.* per OTel)<br/>• langfuse.ts (Langfuse-flavoured GenAI)"]
Expand Down Expand Up @@ -226,10 +226,16 @@ longer recognized: the spec-0001 server reads its checks from the
driver-emitted spans.

**Tool / model → turn attribution** is timestamp-based, not span-tag
based. The OTLP receiver writes `tool_calls.turn_idx` /
`model_usage.turn_idx` as `NULL`; the `analyze-replay` job backfills
those values from the VAD-derived `replay_turns.voice_start_ms..voice_end_ms`
window in the same transaction that writes the turn rows.
based — and derived, not stored. `tool_calls` / `model_usage` rows carry
only their wall-clock `started_at`; turn membership is computed at
eval/read time by mapping `started_at` onto the audio timeline
(`audio_offset_ms = started_at − replays.recording_started_at`, the
anchor the driver sends via the `X-Recording-Started-At` upload header)
and testing the VAD-derived turn window `[turn_start_ms, turn_end_ms)`.
There is no `turn_idx` column on those tables and no backfill stage —
see `docs/specs/0001-timeline-clock-alignment.md` for why
`replays.started_at` (row-creation time) must never be used as the
origin.

**gen_ai semconv** (`gen-ai-semconv.ts`) — `gen_ai.tool` → `tool_calls`,
`gen_ai.client.operation` → `model_usage`. **Langfuse** vocabulary
Expand Down Expand Up @@ -268,15 +274,15 @@ sequenceDiagram
end

D->>D: assemble stereo WAV<br/>(L = user PCM, R = agent PCM,<br/>wall-clock-aligned)
D->>X: POST /v1/replays/:id/audio<br/>→ lifecycle_state='recording_uploaded'
D->>X: POST /v1/replays/:id/audio<br/>+ X-Recording-Started-At (audio t=0)<br/>→ lifecycle_state='recording_uploaded'
D->>X: POST /v1/replays/:id/analyze<br/>→ lifecycle_state='analyzing'
X->>W: bunqueue enqueue analyze-replay
W->>W: read WAV, downsample to 16k,<br/>VAD per channel,<br/>derive turn boundaries,<br/>backfill tool_calls/model_usage turn_idx
W->>W: read WAV, downsample to 16k,<br/>VAD per channel,<br/>derive turn boundaries
W->>X: insert speech_segments + replay_turns<br/>analysis_step='transcribe'
W->>W: slice per-turn audio, call Whisper<br/>(Promise.all over turns)
W->>X: insert turn_transcripts<br/>enqueue calculate-metrics
X->>W: bunqueue enqueue calculate-metrics
W->>W: compute agent_response_ms + ttft_ms<br/>+ interrupted per turn
W->>W: compute agent_response_ms<br/>+ interrupted per turn
W->>X: insert replay_metrics<br/>analysis_step='evaluate'<br/>enqueue evaluate-replay
X->>W: bunqueue enqueue evaluate-replay
W->>W: run each declared Assertion<br/>(pure ts-pattern dispatch)<br/>+ each declared Judge<br/>(OpenAI Chat Completions)
Expand Down
22 changes: 17 additions & 5 deletions docs/integrate.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,16 @@ What `xray.run` does:
3. Bind the driver, attach replay baggage, run the driver — playing
user audio + capturing agent audio + transcripts.
4. Assemble a 48 kHz int16 **stereo WAV** (L = user, R = agent,
wall-clock-aligned) and POST it to `/v1/replays/:id/audio`.
wall-clock-aligned) and POST it to `/v1/replays/:id/audio`, with the
`X-Recording-Started-At` header set to the wall-clock (ISO-8601 UTC) of
audio sample 0. This anchor is the sole origin for mapping span
timestamps onto the audio timeline — the server derives each tool /
model / span row's per-turn membership from it. **A custom `Runtime`
that produces audio MUST report it**: return
`RuntimeResult.recording_started_at_epoch` (Unix epoch seconds of sample
0) and `xray.run` sends the header for you. Omit it and span→turn
attribution is skipped — every `tool_called` / `tool_not_called` /
`tool_args_match` / `max_ttft_ms` assertion comes back `errored`.
5. POST `/v1/replays/:id/analyze` — server enqueues the three-stage
analyze chain (`analyze-replay` → `calculate-metrics` →
`evaluate-replay`).
Expand Down Expand Up @@ -274,7 +283,10 @@ one file each in `src/server/tts/`.
Each carries `judge_idx`, `kind`, `status`, the LLM's 0..100
`score`, and the LLM's natural-language `reason`.
- `metrics: tuple[TurnMetrics, ...]` — per-turn timing computed
server-side: `agent_response_ms`, `ttft_ms`, `interrupted`.
server-side: `agent_response_ms`, `interrupted`. (Model TTFT is a
per-call attribute on the replay's `model_usage` rows, populated when
the agent's instrumentation emits
`gen_ai.response.time_to_first_chunk` — not a per-turn metric.)
- `replay_id` + `conversation_hash` — pointers back to the server-side
rows for follow-up inspection.

Expand Down Expand Up @@ -337,9 +349,9 @@ This release moves assertion + judge evaluation onto the server.
down, judge crashing) raise `ReplayEvaluationError`.
- **Three-stage server chain.** `/analyze` now enqueues
`analyze-replay` (VAD + per-turn Whisper transcription), which
enqueues `calculate-metrics` (agent_response_ms, ttft_ms,
interrupted), which enqueues `evaluate-replay` (runs all assertions
+ judges, emits `evaluation_complete` SSE).
enqueues `calculate-metrics` (agent_response_ms, interrupted), which
enqueues `evaluate-replay` (runs all assertions + judges, emits
`evaluation_complete` SSE).
- **SSE event renamed.** The `completed` event is gone; the chain
emits `evaluation_complete` with the full `ReplayResult` payload.
- **`GET /v1/replays/:id/result`** — fetches the same payload outside
Expand Down
Loading
Loading