Loading documentation

Access required

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

Sign out
Skip to main content

Reflection Ingest

Status: stable Category: trinityassist Added: 2026-05-11 Last reviewed: 2026-07-01

Summary

Persist a signed TrinityAssist case reflection (the typed questions + the clinician's typed answers + sign-off metadata) to the engine's learning corpus. Returns an opaque learning_request_id the consumer stores against its own audit row.

This is the inbound half of the case-reflection loop. Consumers generate questions via /api/case_reflection/questions, collect a typed answer per question from a clinician, sign them off on their side, and forward the signed payload here. Each answer is a { question_id, value } pair whose value type is fixed by the question's kind; the engine validates every present value against its question before writing.

Endpoint

FieldValue
MethodPOST
Path/api/case_reflection/ingest
Full URLhttps://engine.tripod-health.com/api/case_reflection/ingest
AuthenticationAPI key (required)
Request bodyapplication/json
Response bodyapplication/json
IdempotentNo (a duplicate retry returns a fresh learning_request_id)
Rate-limitedYes

Authentication

Standard API-key. See authentication.md. The consumer identity attached to your API key is recorded with every ingested reflection as the per-customer provenance.

Request

Headers

HeaderRequiredNotes
apikeyYesYour engine API key.
Content-TypeYesapplication/json.
X-Request-IDNoEchoed back; lets you correlate logs.

Body

{
"subject_type": "case",
"subject_id": "case-abc123",
"questions": [
{
"question_id": "outcome_matched_expectation",
"prompt_text": "Did the clinical outcome across the case match what you expected when the injury first presented?",
"hint": "Think about the case as a whole, not any single visit.",
"kind": "yes_no"
},
{
"question_id": "confidence_in_care_plan",
"prompt_text": "How confident are you that the care plan you pursued was the right call for this case?",
"kind": "numeric_scale",
"scale": {
"min": 0,
"max": 10,
"min_label": "not at all confident",
"max_label": "completely confident"
}
},
{
"question_id": "primary_recovery_barrier",
"prompt_text": "What was the primary barrier to recovery on this case?",
"kind": "single_select",
"options": [
{ "value": "persistent_pain", "label": "Persistent pain" },
{ "value": "psychosocial", "label": "Psychosocial factors" },
{ "value": "access_to_care", "label": "Access to care" },
{ "value": "none", "label": "No significant barrier" }
]
},
{
"question_id": "case_summary_what_worked",
"prompt_text": "Anything else worth capturing about what worked on this case?",
"kind": "short_text"
}
],
"answers": [
{ "question_id": "outcome_matched_expectation", "value": true },
{ "question_id": "confidence_in_care_plan", "value": 8 },
{ "question_id": "primary_recovery_barrier", "value": "psychosocial" },
{
"question_id": "case_summary_what_worked",
"value": "Early activity coaching and a clear return-to-duty plan kept the patient engaged across follow-ups."
}
],
"signed_off_at": "2026-05-15T14:23:00.000Z",
"signed_off_by_user_id_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"generated_mode": "real"
}
FieldTypeAlways presentDescription
subject_typeenumYesAlways case. Names the consumer-side parent the reflection was signed against.
subject_idstringYesConsumer-supplied opaque correlation token (1-64 chars). Treated as opaque by the engine.
questionsarrayYesThe exact typed question set the engine returned at generation time. 1-50 entries.
questions[].question_idstringYesThe id the engine returned (1-64 chars).
questions[].prompt_textstringYesThe prompt text from generation (1-2000 chars).
questions[].hintstring | nullNoOptional supplemental hint text (max 500 chars).
questions[].kindenumYesOne of yes_no, numeric_scale, date, single_select, multi_select, short_text.
questions[].scaleobject | nullNoRequired on numeric_scale, forbidden otherwise. { min, max, min_label, max_label } -- min/max are the fixed 0/10.
questions[].optionsarray | nullNoRequired on single_select / multi_select (2-6 entries), forbidden otherwise. Each entry is { value, label }.
answersarrayYesOne entry per answered question. 1-50 entries.
answers[].question_idstringYesMust match a question_id present in questions.
answers[].valuetyped | nullYesThe clinician's answer. Its type is fixed by the matching question's kind (see Answer values). null (or empty) marks the question unanswered.
signed_off_atstringYesISO 8601 timestamp at which the consumer locked the reflection. Used as the corpus row's authoritative timestamp.
signed_off_by_user_id_hashstringYesSHA-256 hex of the consumer-side user id (lowercase, exactly 64 chars). The engine never accepts raw user ids.
generated_modeenumYesreal or stub. Submitting stub is rejected with 400 ValidationError (cross-mode contamination guard); only consumer-side real runs may be ingested.

Answer values

Each answers[].value is validated against the kind of the question it references. The engine checks each present value; a value whose type does not match its question's kind fails with 422 UnprocessableEntity. null (or an empty string / empty array) is always accepted -- it marks the question unanswered.

Question kindvalue typeValid values
yes_nobooleantrue or false.
numeric_scaleintegerAn integer in [0, 10] (the question's fixed scale).
datestringA real calendar date "YYYY-MM-DD", or "" for unanswered.
single_selectstringOne of the question's option values, or "" for unanswered.
multi_selectarray of stringsA duplicate-free subset of the question's option values; [] for unanswered.
short_textstringFree text, ≤ 10,000 chars.

The body MUST NOT contain identifier-shaped values inside free-text answers. The engine re-asserts the de-id contract on every free-text answer before the corpus write. See conventions.md.

Response

Success -- 200 OK

{
"accepted": true,
"learning_request_id": "lrn_710cf7065f8ea64ce4b37af913dfa6a6",
"request_id": "req-xyz",
"timestamp": "2026-05-15T14:23:00.123Z"
}

The example above is a real production response. learning_request_id is always lrn_ followed by 32 lowercase hex characters and is unique per accepted ingest.

FieldTypeAlways presentDescription
acceptedbooleanYesAlways true on 200. Consumers should treat any other shape as a non-200 failure.
learning_request_idstringYesEngine-issued correlation id (lrn_<32hex>). Consumers persist this on the audit row that records the ingest.
request_idstring | nullNoEcho of the inbound X-Request-ID.
timestampstringYesISO 8601, UTC.

Status codes

CodeMeaningWhen
200OKReflection persisted to the corpus.
400ValidationErrorBody shape or enum violation, a question whose scale/options do not match its kind, or generated_mode='stub' (stub-generated rows must never reach the learning pipeline).
400DeIdentificationViolationBody or a free-text answer carries an identifier-shaped value.
401UnauthenticatedMissing or invalid API key.
413PayloadTooLargeBody exceeds 512 KB.
415UnsupportedMediaTypeContent-Type is not application/json.
422UnprocessableEntityAn answers[].question_id matches no question in the same body, or an answer value does not match its question's kind (wrong type, out-of-range scale, unknown option, non-calendar date, duplicate multi-select option).
429RateLimitedPer-consumer rate limit exceeded.
500InternalErrorUnexpected failure.
503ServiceUnavailableService temporarily unhealthy.

All non-2xx responses use the uniform error envelope; see errors.md.

Examples

curl

curl -sS -X POST "https://engine.tripod-health.com/api/case_reflection/ingest" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject_type": "case",
"subject_id": "case-abc123",
"questions": [
{ "question_id": "outcome_matched_expectation",
"prompt_text": "Did the clinical outcome across the case match what you expected when the injury first presented?",
"kind": "yes_no" },
{ "question_id": "confidence_in_care_plan",
"prompt_text": "How confident are you that the care plan you pursued was the right call for this case?",
"kind": "numeric_scale",
"scale": { "min": 0, "max": 10, "min_label": "not at all confident", "max_label": "completely confident" } },
{ "question_id": "primary_recovery_barrier",
"prompt_text": "What was the primary barrier to recovery on this case?",
"kind": "single_select",
"options": [
{ "value": "persistent_pain", "label": "Persistent pain" },
{ "value": "psychosocial", "label": "Psychosocial factors" },
{ "value": "access_to_care", "label": "Access to care" },
{ "value": "none", "label": "No significant barrier" }
] }
],
"answers": [
{ "question_id": "outcome_matched_expectation", "value": true },
{ "question_id": "confidence_in_care_plan", "value": 8 },
{ "question_id": "primary_recovery_barrier", "value": "psychosocial" }
],
"signed_off_at": "2026-05-15T14:23:00.000Z",
"signed_off_by_user_id_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"generated_mode": "real"
}'

Python (requests)

# Uses the call() helper from quickstart.md.
import hashlib

user_hash = hashlib.sha256(b"clinician-user-id").hexdigest()

payload = {
"subject_type": "case",
"subject_id": "case-abc123",
"questions": [
{
"question_id": "outcome_matched_expectation",
"prompt_text": "Did the clinical outcome across the case match what you expected when the injury first presented?",
"kind": "yes_no",
},
{
"question_id": "confidence_in_care_plan",
"prompt_text": "How confident are you that the care plan you pursued was the right call for this case?",
"kind": "numeric_scale",
"scale": {
"min": 0,
"max": 10,
"min_label": "not at all confident",
"max_label": "completely confident",
},
},
],
"answers": [
{"question_id": "outcome_matched_expectation", "value": True},
{"question_id": "confidence_in_care_plan", "value": 8},
],
"signed_off_at": "2026-05-15T14:23:00.000Z",
"signed_off_by_user_id_hash": user_hash,
"generated_mode": "real",
}

result = call("POST", "/api/case_reflection/ingest", payload)
print("learning_request_id:", result["learning_request_id"])

Node.js (native fetch)

// Uses the call() helper from quickstart.md.
import { createHash } from "node:crypto";

const userHash = createHash("sha256").update("clinician-user-id").digest("hex");

const result = await call("POST", "/api/case_reflection/ingest", {
subject_type: "case",
subject_id: "case-abc123",
questions: [
{
question_id: "outcome_matched_expectation",
prompt_text:
"Did the clinical outcome across the case match what you expected when the injury first presented?",
kind: "yes_no",
},
{
question_id: "confidence_in_care_plan",
prompt_text:
"How confident are you that the care plan you pursued was the right call for this case?",
kind: "numeric_scale",
scale: {
min: 0,
max: 10,
min_label: "not at all confident",
max_label: "completely confident",
},
},
],
answers: [
{ question_id: "outcome_matched_expectation", value: true },
{ question_id: "confidence_in_care_plan", value: 8 },
],
signed_off_at: "2026-05-15T14:23:00.000Z",
signed_off_by_user_id_hash: userHash,
generated_mode: "real",
});

console.log("learning_request_id:", result.learning_request_id);

Intended use

  • Forward a signed TrinityAssist case reflection into the engine's learning corpus. The submitted record is the labeled-outcome ground truth the engine uses to improve future TrinityAssist generations.
  • Send back the exact typed questions the engine returned, paired with one typed { question_id, value } answer per question. Leave a value null (or empty) for any question the clinician skipped.
  • Persist the returned learning_request_id against your own audit row so a "this reflection was ingested" record is auditable end-to-end.
  • Drive consumer-side retry semantics: a non-200 is the trigger for the consumer's sweeper to back off and retry; 200 is the trigger to mark the row as sent.

Mistaken use

  • Sending answers as { question_id, answer_text } strings. Answers are typed { question_id, value } now; a value whose type does not match its question's kind is rejected with 422.
  • Submitting generated_mode: "stub" to test the endpoint -- the cross-mode contamination guard rejects this with 400. Set up a real generation flow first.
  • Using this endpoint for any non-reflection payload. Reflections have a fixed shape; future learning surfaces will have their own paths.
  • Hashing the user id with anything other than SHA-256, or sending raw user ids. The engine never accepts identifying tokens; the hashing requirement is non-negotiable.
  • Sending a duplicate ingest after a successful 200. Duplicate ingests are not deduplicated server-side; you'll get a new learning_request_id and an additional accepted record. Your client-side retry sweeper (recommended cadence 10s / 60s / 300s) is the authoritative dedup boundary.

De-identification contract

This endpoint enforces the de-id contract on inbound text:

  • The top-level body is walked for blocked field names and identifier- shaped strings (400 DeIdentificationViolation).
  • Every free-text answer value is re-walked for identifier shapes (SSN, email, phone, MRN-like) before the corpus row is written. Failures return 400 DeIdentificationViolation with the offending path.

Persistence into the learning corpus is gated by the same identifier- shape scan even on payloads that pass the top-level walk -- the contract is absolute. See conventions.md.

Rate limits

Per-consumer limits are managed at the gateway. See rate-limits.md.

Change history

DateChange
2026-05-11Initial publication.
2026-07-01Renamed from /api/learn/outcome/ingest to /api/case_reflection/ingest; answers are now typed { question_id, value } (value type fixed by the question's kind, validated with 422 on mismatch) instead of { question_id, answer_text } strings. subject_type is now case only.