CDRI Extract (transcript to answers)
Status:
betaCategory:compositeAdded:2026-08-12Last reviewed:2026-09-16
Summary
POST /api/cdri/extract mines a finished visit's de-identified, diarized
transcript for the CDRI question-bank questions the conversation already
answered — each with a value, a confidence tier, and the verbatim quote behind
it.
It is the third face of the CDRI surface: ask at
/cdri/questions, mine here what the
conversation already settled, score everything at
/cdri/compute. An extracted answer carries the
same meaning as one a person typed, over the same item_ids and the same
values.
How it works (for integrators)
- Accepting an answer is a copy, not a reshape. Each result is
{answer: {item_id, value}, confidence, quote}. Theanswerobject is exactly what/cdri/computetakes — post the ones you accept straight through.confidenceandquotesit outside it because the compute endpoint forbids unknown fields, and that strictness is what catches a misspelled field on the scoring path. - The whole active bank is mined. All 107 questions, in concurrent batches
of 28, each batch seeing the whole transcript. There is no
instrumentsparameter and no rubric involved. - Precision over coverage. Fifteen answers out of a hundred-odd is a good result. A question is answered only when the conversation settles it — silence on a topic is never read as a low score.
- Every answer is validated deterministically after the model returns,
against the bank row and against the transcript itself. An answer whose
supporting quote is not actually in the transcript is dropped. Drops are
counted in
prune_reasons, not raised. - Stateless. Nothing is stored and no corpus row is written. Intended to be called once, at the end of a visit.
- You bring the transcript. The engine has no WebSocket surface and does not transcribe audio.
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/cdri/extract |
| Full URL | https://engine.tripod-health.com/api/cdri/extract |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | No — model-backed; repeated calls on the same transcript can differ at the margin |
| Rate-limited | Yes |
Authentication
Every request must include a valid API key in the apikey header. See
authentication.
Request
Headers
| Header | Required | Description |
|---|---|---|
apikey | Yes | Consumer API key. |
Content-Type | Yes | application/json. |
Body
| Field | Type | Required | Description |
|---|---|---|---|
sessions | array | Yes | The visit's capture sessions. A visit that drops and reconnects produces several. Maximum 32. |
sessions[].session_id | string | Yes | Your identifier for the capture. 1–64 characters. |
sessions[].started_at | string | Yes | When the capture began. 1–64 characters. |
sessions[].segments | array | Yes | Diarized utterances, in order. 1–4096 per session. |
sessions[].segments[].speaker | enum | Yes | provider, patient, other, or unknown. |
sessions[].segments[].speaker_label | string | No | Free-text label for the speaker. Maximum 128 characters. |
sessions[].segments[].text | string | Yes | What was said. 1–8000 characters. |
sessions[].segments[].started_at_ms | integer | Yes | Offset from the start of the session. |
sessions[].segments[].ended_at_ms | integer | Yes | Offset from the start of the session. |
No other fields are accepted, at any depth. An extra key is a 400 naming it.
Send the complete transcript of a finished visit. There is no incremental mode, no rolling window, and no partial-transcript contract.
{
"sessions": [
{
"session_id": "visit-4417",
"started_at": "2026-08-12T14:02:00Z",
"segments": [
{
"speaker": "provider",
"speaker_label": "Provider",
"text": "Tell me where the back is at today, on a zero to ten.",
"started_at_ms": 0,
"ended_at_ms": 4000
},
{
"speaker": "patient",
"speaker_label": "Patient",
"text": "Right now it's about a seven. It was a nine over the weekend when I tried to help move a couch.",
"started_at_ms": 4000,
"ended_at_ms": 13000
}
]
}
]
}
Query parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
min_confidence | enum | medium | high, medium, or low. Filters the answers array. answers_by_confidence always reports the unfiltered counts. |
The transcript is scrubbed, not rejected
This is the one endpoint where identifier-shaped text does not fail the
request. Everywhere else in the engine, something that looks like an identifier
is a 400 (see conventions). Here the transcript fields are
redacted on the way in and the call proceeds — a visit recording naturally
contains names, and failing the whole pass would make the endpoint unusable for
its own purpose.
Two consequences:
- Every field that is not transcript text is still rejected outright. The blunt check runs over the rest of the body exactly as it does elsewhere.
- The de-identification duty is still yours. Redaction is a backstop, not a
licence to send raw PHI. A quote that still carries something
identifier-shaped after scrubbing is dropped rather than returned, and counted
as
quote_unsafe.
Confidence is a tier, not a score
confidence is categorical because it is routing information, not a
measurement. A model's numeric self-assessment of an extraction is not
calibrated against anything; three buckets that mean something operational are
more honest and more useful:
| Tier | Meaning | What to do with it |
|---|---|---|
high | The transcript states the answer and exactly one value fits. | Accept it. |
medium | It took a step of interpretation. A careful reader would agree, but it is worth a human glance. | Accept it provisionally and give someone a way to confirm or remove it. |
low | A careful reader could disagree. | Discard, unless you have a review surface and a reason. |
min_confidence defaults to medium, which is the tier most integrations
want: low answers are noise in a scored result, and dropping them at the
boundary is cheaper than filtering them downstream.
The quote is the evidence
Every answer carries the verbatim words it was drawn from, and the engine confirms that quote really appears in the transcript before returning it. That check is the one thing standing between you and a confident, well-formed, entirely invented answer — which is why the prompt insists on copying rather than paraphrasing, and why a failed check drops the answer rather than softening it.
If you retain the answers and discard the transcript, the quote is the last copy of what was actually said. Store it. An answer a clinician cannot check against anything is not reviewable, and a review surface without evidence is a rubber stamp.
Quotes are bounded at 600 characters: a supporting quote, never a retained transcript.
Response
Success — 200 OK
A real pass over a six-turn back-injury transcript:
{
"answers": [
{
"answer": { "item_id": "pain_intensity_now", "value": 4 },
"confidence": "medium",
"quote": "Right now it's about a seven. It was a nine over the weekend when I tried to help move a couch."
},
{
"answer": { "item_id": "difficulty_personal_care", "value": 3 },
"confidence": "medium",
"quote": "I can dress myself but it hurts the whole time and I have to go slow."
},
{
"answer": { "item_id": "heavy_home_activities", "value": 6 },
"confidence": "high",
"quote": "Lifting anything heavy off the floor is completely out, I just can't do it."
},
{
"answer": { "item_id": "difficulty_lifting", "value": 6 },
"confidence": "high",
"quote": "Lifting anything heavy off the floor is completely out, I just can't do it."
},
{
"answer": { "item_id": "expect_usual_work_6_months", "value": 1 },
"confidence": "high",
"quote": "Honestly, yeah, I'd say very likely. I've had a strain before and it settled down in a month or so."
}
],
"count": 5,
"min_confidence": "medium",
"answers_by_confidence": { "low": 0, "medium": 2, "high": 3 },
"withheld_below_confidence": 0,
"bank_version": "2026-09-15-standard-ladder",
"items_considered": 107,
"batches_extracted": ["1-28", "29-56", "57-84", "85-107"],
"batches_failed": [],
"pruned": 0,
"prune_reasons": {},
"notes": [],
"catalog_version": "0.36.0",
"request_id": "af00d804-b034-429d-8b81-3d6c056d8f84",
"timestamp": "2026-09-16T01:05:48.809948+00:00"
}
Two things in that result generalise.
One sentence can answer several questions. "Lifting anything heavy off the
floor is completely out" answered both heavy_home_activities and
difficulty_lifting, which is why the same quote appears twice. Mining the
whole bank in one pass is what finds that; asking about one topic at a time
would not.
Yield tracks ground covered, not time elapsed. Five answers from six turns is a healthy rate. A topic never raised stays unanswered no matter how long the visit ran.
Response fields
| Field | Type | Always present | Description |
|---|---|---|---|
answers | array | Yes | The extracted answers at or above min_confidence. Empty is a normal result for a short or off-topic visit. |
answers[].answer | object | Yes | Exactly what /cdri/compute accepts. Post it unchanged. |
answers[].answer.item_id | string | Yes | The bank question answered. |
answers[].answer.value | integer | Yes | A code that question offers, validated against the bank row. |
answers[].confidence | string | Yes | high, medium, or low. |
answers[].quote | string | Yes | The verbatim supporting words, confirmed present in the transcript. |
count | integer | Yes | answers.length after the min_confidence filter. |
min_confidence | string | Yes | The filter in effect. |
answers_by_confidence | object | Yes | Counts per tier before filtering. Use it to see what a different threshold would have returned. |
withheld_below_confidence | integer | Yes | How many answers the filter removed. |
bank_version | string | Yes | The question bank version mined. Record it alongside answers you keep. |
items_considered | integer | Yes | Questions in the active bank the pass looked for. 107 today. |
batches_extracted | array of string | Yes | The bank slices that returned, labelled by position. |
batches_failed | array of string | Yes | Slices that did not. See Partial passes. |
pruned | integer | Yes | Answers the model produced and validation rejected. |
prune_reasons | object | Yes | Counts per reason. See below. |
notes | array of string | Yes | Plain-language warnings. Surface in a developer console, not to an end user. |
catalog_version | string | Yes | Catalog version that served the request. |
request_id | string | Yes | Correlation ID. |
timestamp | string (ISO 8601) | Yes | Server time at response. |
prune_reasons
| Reason | Meaning |
|---|---|
quote_not_in_transcript | The supporting quote was never said. The check that catches invented answers. |
illegal_value | The value is not a code that question offers. |
unknown_item | The item_id is not in the active bank. |
quote_too_short | The quote is too short to be evidence of anything. |
duplicate | The same question was answered twice; the lower-confidence one was dropped. |
quote_unsafe | The quote still carried something identifier-shaped after inbound scrubbing, so it could not be returned. Rare; report it with the request_id. |
Some pruning on a pass is normal and healthy — it is the validation layer doing
its job. A prune count that rivals or exceeds count is not; that usually means
the transcript is thin, off-topic, or badly diarized.
Partial passes
Bank batches are mined concurrently and independently. If some fail, the
response carries the rest and names the failures in batches_failed, with a
note. A partial pass says so rather than looking complete.
Treat a non-empty batches_failed as "these questions were not examined", not
as "these questions had nothing". The distinction matters if you are about to
destroy the transcript.
Status codes
| Code | Meaning | When |
|---|---|---|
200 | OK | Extraction ran. Zero answers is a 200. |
400 | ValidationError | Malformed body, no segments, an out-of-range field, or an unexpected key. field names it. |
400 | DeIdentificationViolation | A field other than transcript text carried something identifier-shaped. |
401 | Unauthenticated | Missing or invalid API key. |
402 | PaymentRequired | Insufficient credits. See credits. |
429 | RateLimited | Rate limit exceeded. |
502 | UpstreamError | Every batch failed. Retry with backoff. |
503 | ServiceUnavailable | The question bank could not be loaded, so there was nothing to mine against. Checked before the model runs, so a failed request is not billed for model work. |
504 | UpstreamTimeout | The pass exceeded its budget. Retry with backoff. |
Examples
1. Canonical call
curl -sS -X POST "https://engine.tripod-health.com/api/cdri/extract" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d @transcript.json
2. Mining a visit, then scoring what it found
# Uses the call() helper from quickstart.md.
mined = call("POST", "/api/cdri/extract?min_confidence=medium", transcript)
# Accepting an answer is a copy. Nothing is reshaped on the way through.
accepted = [item["answer"] for item in mined["answers"]]
result = call("POST", "/api/cdri/compute", {"answers": accepted})
print(result["cdri"]["score"], result["cdri"]["band"])
3. High-confidence only, with a review queue for the rest
// Uses the call() helper from quickstart.md.
const mined = await call(
"POST", "/api/cdri/extract?min_confidence=low", transcript,
);
const auto = mined.answers.filter((a) => a.confidence === "high");
const review = mined.answers.filter((a) => a.confidence !== "high");
// Score what is unambiguous; put the rest in front of a person, with the quote.
await call("POST", "/api/cdri/compute", {
answers: auto.map((a) => a.answer),
});
Ask for low and filter client-side when you have a review surface —
answers_by_confidence tells you what you are choosing between before you
commit to a threshold.
Intended use
- Closing out a visit. Call once at sign-off, before the transcript is destroyed, and keep the answers and their quotes.
- Reducing double entry. Anything the conversation already settled does not
need to be asked again at
/cdri/questions— send the mined answers as your accumulatedanswersand those questions leave the pool. - Populating a review queue.
mediumanswers with their quotes are exactly the shape a human-confirmation surface needs.
Mistaken use
- Do not treat an empty result as a healthy visit. It means the conversation
did not settle anything the bank asks about. Check
batches_failedbefore concluding anything at all. - Do not send a partial or live transcript. There is no streaming contract. Call it once, at the end.
- Do not discard the quotes. They are the only evidence for an answer no one typed.
- Do not auto-accept
low. It exists so you can see what the pass considered, not so you can score it. - Do not expect an instrument. This mines the question bank. To score a
published instrument, administer it and use its own
/scoreendpoint. - Do not call it per topic. One pass covers the whole bank, and the cross-cutting reads — one sentence answering several questions — only happen because every batch sees the whole transcript.
Debugging
- Capture
request_idfrom the response body (or, if the response has no body from the engine, theX-Request-IDresponse header). - Zero answers. Read
answers_by_confidence— if it is non-zero below your threshold, the pass found things and your filter removed them. If it is all zero, the conversation did not settle anything. prunedis high relative tocount. Readprune_reasons. A largequote_not_in_transcriptcount against a badly diarized transcript is the usual cause; fix the diarization before blaming the extraction.- A
400naming a field. The body accepts only the fields in the table above. Anything else, at any depth, is rejected by name. - A
502. Every batch failed. That is an engine-side fault, not a thin transcript — retry with backoff. - Answers you disagree with. Compare
quoteagainst what was said. The quote is guaranteed to be in the transcript; whether it supports that value is the judgement the tier is telling you about.
Rate limits
See rate-limits.md.
Related
- composite-cdri-compute.md — post the accepted answers here.
- composite-cdri-questions.md — ask for what the conversation did not settle.
- encounters-document-draft.md — the other endpoint that reads a visit transcript, for a coded encounter note or a check-in's narrative note.
- conventions.md — the de-identification boundary.
- credits.md — this is a model-backed route; see the pricing note.
Change history
| Date | Change |
|---|---|
| 2026-08-12 | Initial publication. |
| 2026-09-16 | Rewritten for the question bank. The whole active bank is mined in batches; instruments, domain, instrument_versions, domains_extracted and domains_failed are gone, replaced by bank_version, batches_extracted and batches_failed. An answer is now nested under answer so it can be posted to /cdri/compute unchanged. |
| 2026-10-14 | The engine assigns every request ID; an X-Request-ID request header is not used. The X-Request-ID response header is the gateway's trace ID for the request and does not match request_id; quote request_id to support. |