Reflection Ingest
Status:
stableCategory:trinityassistAdded:2026-05-11Last 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
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/case_reflection/ingest |
| Full URL | https://engine.tripod-health.com/api/case_reflection/ingest |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | No (a duplicate retry returns a fresh learning_request_id) |
| Rate-limited | Yes |
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
| Header | Required | Notes |
|---|---|---|
apikey | Yes | Your engine API key. |
Content-Type | Yes | application/json. |
X-Request-ID | No | Echoed 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"
}
| Field | Type | Always present | Description |
|---|---|---|---|
subject_type | enum | Yes | Always case. Names the consumer-side parent the reflection was signed against. |
subject_id | string | Yes | Consumer-supplied opaque correlation token (1-64 chars). Treated as opaque by the engine. |
questions | array | Yes | The exact typed question set the engine returned at generation time. 1-50 entries. |
questions[].question_id | string | Yes | The id the engine returned (1-64 chars). |
questions[].prompt_text | string | Yes | The prompt text from generation (1-2000 chars). |
questions[].hint | string | null | No | Optional supplemental hint text (max 500 chars). |
questions[].kind | enum | Yes | One of yes_no, numeric_scale, date, single_select, multi_select, short_text. |
questions[].scale | object | null | No | Required on numeric_scale, forbidden otherwise. { min, max, min_label, max_label } -- min/max are the fixed 0/10. |
questions[].options | array | null | No | Required on single_select / multi_select (2-6 entries), forbidden otherwise. Each entry is { value, label }. |
answers | array | Yes | One entry per answered question. 1-50 entries. |
answers[].question_id | string | Yes | Must match a question_id present in questions. |
answers[].value | typed | null | Yes | The 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_at | string | Yes | ISO 8601 timestamp at which the consumer locked the reflection. Used as the corpus row's authoritative timestamp. |
signed_off_by_user_id_hash | string | Yes | SHA-256 hex of the consumer-side user id (lowercase, exactly 64 chars). The engine never accepts raw user ids. |
generated_mode | enum | Yes | real 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 kind | value type | Valid values |
|---|---|---|
yes_no | boolean | true or false. |
numeric_scale | integer | An integer in [0, 10] (the question's fixed scale). |
date | string | A real calendar date "YYYY-MM-DD", or "" for unanswered. |
single_select | string | One of the question's option values, or "" for unanswered. |
multi_select | array of strings | A duplicate-free subset of the question's option values; [] for unanswered. |
short_text | string | Free 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.
| Field | Type | Always present | Description |
|---|---|---|---|
accepted | boolean | Yes | Always true on 200. Consumers should treat any other shape as a non-200 failure. |
learning_request_id | string | Yes | Engine-issued correlation id (lrn_<32hex>). Consumers persist this on the audit row that records the ingest. |
request_id | string | null | No | Echo of the inbound X-Request-ID. |
timestamp | string | Yes | ISO 8601, UTC. |
Status codes
| Code | Meaning | When |
|---|---|---|
200 | OK | Reflection persisted to the corpus. |
400 | ValidationError | Body 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). |
400 | DeIdentificationViolation | Body or a free-text answer carries an identifier-shaped value. |
401 | Unauthenticated | Missing or invalid API key. |
413 | PayloadTooLarge | Body exceeds 512 KB. |
415 | UnsupportedMediaType | Content-Type is not application/json. |
422 | UnprocessableEntity | An 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). |
429 | RateLimited | Per-consumer rate limit exceeded. |
500 | InternalError | Unexpected failure. |
503 | ServiceUnavailable | Service 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 valuenull(or empty) for any question the clinician skipped. - Persist the returned
learning_request_idagainst your own audit row so a "this reflection was ingested" record is auditable end-to-end. - Drive consumer-side retry semantics: a non-
200is the trigger for the consumer's sweeper to back off and retry;200is the trigger to mark the row assent.
Mistaken use
- Sending answers as
{ question_id, answer_text }strings. Answers are typed{ question_id, value }now; avaluewhose type does not match its question'skindis rejected with422. - Submitting
generated_mode: "stub"to test the endpoint -- the cross-mode contamination guard rejects this with400. 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 newlearning_request_idand 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 DeIdentificationViolationwith 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.
Related
POST /api/case_reflection/questions-- generate the typed case-level questions.- conventions.md -- de-identification contract, request IDs, timestamp shape.
- errors.md -- error envelope and code list.
Change history
| Date | Change |
|---|---|
| 2026-05-11 | Initial publication. |
| 2026-07-01 | Renamed 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. |