Loading documentation

Access required

Your account doesn't have access to the documentation. Contact your administrator.

Sign out
Skip to main content

CDRI Extract (transcript to answers)

Status: beta Category: composite Added: 2026-08-12 Last 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}. The answer object is exactly what /cdri/compute takes — post the ones you accept straight through. confidence and quote sit 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 instruments parameter 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​

FieldValue
MethodPOST
Path/api/cdri/extract
Full URLhttps://engine.tripod-health.com/api/cdri/extract
AuthenticationAPI key (required)
Request bodyapplication/json
Response bodyapplication/json
IdempotentNo — model-backed; repeated calls on the same transcript can differ at the margin
Rate-limitedYes

Authentication​

Every request must include a valid API key in the apikey header. See authentication.

Request​

Headers​

HeaderRequiredDescription
apikeyYesConsumer API key.
Content-TypeYesapplication/json.

Body​

FieldTypeRequiredDescription
sessionsarrayYesThe visit's capture sessions. A visit that drops and reconnects produces several. Maximum 32.
sessions[].session_idstringYesYour identifier for the capture. 1–64 characters.
sessions[].started_atstringYesWhen the capture began. 1–64 characters.
sessions[].segmentsarrayYesDiarized utterances, in order. 1–4096 per session.
sessions[].segments[].speakerenumYesprovider, patient, other, or unknown.
sessions[].segments[].speaker_labelstringNoFree-text label for the speaker. Maximum 128 characters.
sessions[].segments[].textstringYesWhat was said. 1–8000 characters.
sessions[].segments[].started_at_msintegerYesOffset from the start of the session.
sessions[].segments[].ended_at_msintegerYesOffset 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​

ParameterTypeDefaultDescription
min_confidenceenummediumhigh, 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:

TierMeaningWhat to do with it
highThe transcript states the answer and exactly one value fits.Accept it.
mediumIt 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.
lowA 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​

FieldTypeAlways presentDescription
answersarrayYesThe extracted answers at or above min_confidence. Empty is a normal result for a short or off-topic visit.
answers[].answerobjectYesExactly what /cdri/compute accepts. Post it unchanged.
answers[].answer.item_idstringYesThe bank question answered.
answers[].answer.valueintegerYesA code that question offers, validated against the bank row.
answers[].confidencestringYeshigh, medium, or low.
answers[].quotestringYesThe verbatim supporting words, confirmed present in the transcript.
countintegerYesanswers.length after the min_confidence filter.
min_confidencestringYesThe filter in effect.
answers_by_confidenceobjectYesCounts per tier before filtering. Use it to see what a different threshold would have returned.
withheld_below_confidenceintegerYesHow many answers the filter removed.
bank_versionstringYesThe question bank version mined. Record it alongside answers you keep.
items_consideredintegerYesQuestions in the active bank the pass looked for. 107 today.
batches_extractedarray of stringYesThe bank slices that returned, labelled by position.
batches_failedarray of stringYesSlices that did not. See Partial passes.
prunedintegerYesAnswers the model produced and validation rejected.
prune_reasonsobjectYesCounts per reason. See below.
notesarray of stringYesPlain-language warnings. Surface in a developer console, not to an end user.
catalog_versionstringYesCatalog version that served the request.
request_idstringYesCorrelation ID.
timestampstring (ISO 8601)YesServer time at response.

prune_reasons​

ReasonMeaning
quote_not_in_transcriptThe supporting quote was never said. The check that catches invented answers.
illegal_valueThe value is not a code that question offers.
unknown_itemThe item_id is not in the active bank.
quote_too_shortThe quote is too short to be evidence of anything.
duplicateThe same question was answered twice; the lower-confidence one was dropped.
quote_unsafeThe 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​

CodeMeaningWhen
200OKExtraction ran. Zero answers is a 200.
400ValidationErrorMalformed body, no segments, an out-of-range field, or an unexpected key. field names it.
400DeIdentificationViolationA field other than transcript text carried something identifier-shaped.
401UnauthenticatedMissing or invalid API key.
402PaymentRequiredInsufficient credits. See credits.
429RateLimitedRate limit exceeded.
502UpstreamErrorEvery batch failed. Retry with backoff.
503ServiceUnavailableThe 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.
504UpstreamTimeoutThe 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 accumulated answers and those questions leave the pool.
  • Populating a review queue. medium answers 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_failed before 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 /score endpoint.
  • 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​

  1. Capture request_id from the response body (or, if the response has no body from the engine, the X-Request-ID response header).
  2. 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.
  3. pruned is high relative to count. Read prune_reasons. A large quote_not_in_transcript count against a badly diarized transcript is the usual cause; fix the diarization before blaming the extraction.
  4. A 400 naming a field. The body accepts only the fields in the table above. Anything else, at any depth, is rejected by name.
  5. A 502. Every batch failed. That is an engine-side fault, not a thin transcript — retry with backoff.
  6. Answers you disagree with. Compare quote against 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.

Change history​

DateChange
2026-08-12Initial publication.
2026-09-16Rewritten 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-14The 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.