openapi: 3.1.0 info: title: MediMergent — Clinical Trials AI version: "1.0.0" description: | HTTP API for MediMergent, the clinical-trials patient-consultation service ("Carmen"). It fronts a retrieval (RAG) backend and wires together: * **Patient consultation** — chat (plain + streaming SSE), greeting, history, TTS audio, avatar/speech tokens. All of these require a **session cookie** obtained by redeeming a one-time invite link (or an admin test session). * **Admin panel** — trial lifecycle (create → upload documents → extract → index → activate), invite management, conversation/call review. All admin routes use **HTTP Basic auth** (username `admin`). * **Health** — unauthenticated liveness/config probe. ### Authentication summary | Area | Scheme | How | |---|---|---| | `/api/health` | none | — | | Patient `/api/*` | `carmen_session` cookie | set by `POST /invite/{token}/redeem` or `POST /admin/test-session` | | `/api/admin/*`, `/api/calls`, `/api/conversations*` | HTTP Basic | user `admin`, password `ADMIN_PASSWORD` env var | ### Errors Errors use FastAPI's standard envelope: `{"detail": "..."}` (or a list of validation errors for 422). Missing/expired session ⇒ `401` with `"Active Carmen session required"`; missing/wrong Basic credentials ⇒ `401` with a `WWW-Authenticate: Basic` header. ### Not in this file `WS /api/stt/stream` is a WebSocket (browser mic → Amazon Transcribe proxy, active only when `STT_BACKEND=transcribe`) and cannot be expressed in OpenAPI; it accepts binary PCM frames and sends back JSON transcript events. Server-rendered HTML pages (`/`, `/carmen-new`, `/dashboard`, `/admin/*` pages) are omitted except the invite pages, which are the entry point for patient sessions. ### Related documentation The running service serves human documentation at `/docs` (usage guide + developer guide), this spec at `/docs/openapi.yaml`, and FastAPI's auto-generated Swagger UI at `/api-docs`. servers: - url: http://localhost:8200 description: Local development (uvicorn, Docker default port) - url: https://{host} description: Deployed environment variables: host: default: medimergent.example.com tags: - name: Health description: Unauthenticated service probe. - name: Session description: Getting and ending a patient session (invite redemption). - name: Chat description: Carmen conversation endpoints. Require the session cookie. - name: RAG description: Direct query/index access to the retrieval backend. - name: Voice & Avatar description: TTS audio and third-party tokens for the avatar and browser STT. - name: Trial (patient) description: Public, non-sensitive trial info for the consultation UI. - name: Admin — Trials description: Trial lifecycle management (Basic auth). - name: Admin — Documents description: Trial document upload/download/indexing (Basic auth). - name: Admin — Infographics description: Static trial image infographic upload/download (Basic auth). - name: Admin — Invites description: One-time patient invite links (Basic auth). - name: Admin — Conversations & Calls description: Review finished conversations and call records (Basic auth). - name: Admin — Maintenance description: Destructive reset operations (Basic auth). Use with care. security: [] paths: # ── Health ────────────────────────────────────────────────────────────── /api/health: get: tags: [Health] summary: Service health and configuration snapshot description: > Reports build info, trial counts, the trials storage backend, and the result of a live health probe of the retrieval backend (an error object if it is unreachable — the endpoint itself still returns 200). operationId: getHealth responses: "200": description: Service is up. content: application/json: example: status: healthy service: medimergent build_tag: "v1.4.2" build_sha: "5411f59" trials_loaded: 3 trials_enabled: 1 trials_backend: dynamodb dynamodb_available: true textsentry: status: healthy # ── Session (invite redemption) ───────────────────────────────────────── /invite/{token}: get: tags: [Session] summary: Invite welcome page (HTML) description: > Patient-facing welcome page for a one-time invite link. Renders trial info and a "start consultation" form. `404` invalid token, `410` expired/already-used invite or unavailable trial. operationId: inviteWelcome parameters: - $ref: "#/components/parameters/InviteToken" responses: "200": { description: Welcome page., content: { text/html: {} } } "404": { description: Invitation not found., content: { text/html: {} } } "410": { description: "Invitation expired/used, or trial unavailable.", content: { text/html: {} } } /invite/{token}/redeem: post: tags: [Session] summary: Redeem an invite — creates the patient session description: | Marks the one-time invite as used, creates a 7-day session bound to the invite's trial, sets the **`carmen_session`** cookie (HttpOnly, SameSite=Lax) and redirects (303) to the consultation console `/carmen-new`. This cookie is the credential for every patient `/api/*` endpoint below. Re-posting a used token from the same browser (valid matching session still present) also redirects instead of failing. operationId: redeemInvite parameters: - $ref: "#/components/parameters/InviteToken" responses: "303": description: Session created; redirect to the consultation console. headers: Set-Cookie: schema: { type: string } description: "`carmen_session=; Max-Age=604800; HttpOnly; SameSite=Lax; Path=/`" Location: schema: { type: string } description: "`/carmen-new`" "404": { description: Invitation not found., content: { text/html: {} } } "410": { description: "Invitation expired/used, or trial unavailable.", content: { text/html: {} } } /api/conversation/end: post: tags: [Session, Chat] summary: End the consultation and finalize the call record description: > Finalizes the session's conversation: runs an LLM analysis of the transcript, stores a call record, indexes the summary/transcript into RAG in the background, expires the session, and clears the `carmen_session` cookie. After this the patient needs a new invite. operationId: endConversation security: [{ sessionCookie: [] }] responses: "200": description: Conversation finalized. content: application/json: example: status: ended conversation_id: C-7Q2M4KP9XT1V analysis: summary: Patient asked about infusion visits and nausea management. call_reason: trial_inquiry sentiment: positive eligibility_result: not_screened resolution: resolved "401": { $ref: "#/components/responses/SessionRequired" } # ── Chat ──────────────────────────────────────────────────────────────── /api/chat: post: tags: [Chat] summary: Send a message to Carmen (blocking) description: | Appends the user message to the conversation, retrieves RAG context scoped to the session's trial, and returns Carmen's full reply in one response. * `response` — display text (SSML stripped) for the chat bubble/transcript. * `spoken` — TTS text (markdown stripped, inline SSML kept). * `meta` — response stats, only when `include_meta` is true. operationId: chat security: [{ sessionCookie: [] }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ChatRequest" } example: message: What side effects should I expect in the first week? response_style: brief responses: "200": description: Carmen's reply. content: application/json: example: response: The most common early side effects are mild nausea and fatigue. Would you like tips for managing them? spoken: The most common early side effects are mild nausea and fatigue. Would you like tips for managing them? conversation_id: C-7Q2M4KP9XT1V "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/SessionRequired" } "500": { description: LLM backend failure., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } /api/chat/stream: post: tags: [Chat] summary: Send a message to Carmen (Server-Sent Events) description: | Same as `POST /api/chat` but streams the reply sentence-by-sentence as SSE. Each event's `data:` is JSON: * `{"sentence": "", "spoken": ""}` — one complete sentence. * `{"type": "meta", "phase": "post", ...stats}` — once, before the end, only if `include_meta`. * `{"error": "..."}` — on backend failure mid-stream. * `[DONE]` — literal string terminator. Aborting the request (patient barge-in) stores the partial reply marked `(interrupted)`; see `/api/chat/interrupted` for the audio-playout case. operationId: chatStream security: [{ sessionCookie: [] }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/ChatRequest" } example: message: How long does each infusion take? responses: "200": description: SSE stream of sentences. content: text/event-stream: schema: { type: string } example: | data: {"sentence": "Your first infusion takes about 90 minutes.", "spoken": "Your first infusion takes about 90 minutes."} data: {"sentence": "After that, visits are usually closer to 60 minutes.", "spoken": "After that, visits are usually closer to 60 minutes."} data: [DONE] "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/SessionRequired" } /api/chat/interrupted: post: tags: [Chat] summary: Report where audio playback was cut off description: > Interruption honesty for the transcript. When the patient cuts Carmen off while the *audio* is still playing (the text stream already finished), only the client knows how much was heard. It posts the heard prefix here; the stored assistant turn is trimmed to it and stamped `(interrupted)`. Safe to call unconditionally — a prefix that doesn't match the stored turn is ignored (`"ignored"`), never corruption. operationId: reportInterrupted security: [{ sessionCookie: [] }] requestBody: required: true content: application/json: schema: type: object properties: spoken: type: string description: The words the patient actually heard. default: "" example: spoken: Your first infusion takes about 90 minutes. responses: "200": description: Whether the stored turn was rewritten. content: application/json: examples: marked: { value: { status: marked } } ignored: { value: { status: ignored } } "401": { $ref: "#/components/responses/SessionRequired" } /api/chat/history: get: tags: [Chat] summary: Get the session's conversation history operationId: getChatHistory security: [{ sessionCookie: [] }] responses: "200": description: All messages in the current conversation. content: application/json: schema: type: object properties: messages: type: array items: { $ref: "#/components/schemas/ChatMessage" } example: messages: - { role: assistant, content: "Hello! I'm Carmen, your care coordinator..." } - { role: user, content: "How long does each infusion take?" } - { role: assistant, content: "Your first infusion takes about 90 minutes. (interrupted)" } "401": { $ref: "#/components/responses/SessionRequired" } /api/greeting: get: tags: [Chat] summary: Get Carmen's scripted greeting (and record it) description: > Returns the onboarding greeting and appends it to the conversation as an assistant turn. Call once when the consultation UI loads. operationId: getGreeting security: [{ sessionCookie: [] }] responses: "200": description: Greeting text (display and spoken are identical). content: application/json: example: response: "Hello! I'm Carmen, your care coordinator..." spoken: "Hello! I'm Carmen, your care coordinator..." "401": { $ref: "#/components/responses/SessionRequired" } /api/clear: post: tags: [Chat] summary: Clear the conversation history description: Clears stored messages and starts a fresh summary row for the same conversation ID. operationId: clearChat security: [{ sessionCookie: [] }] responses: "200": description: History cleared. content: application/json: example: { status: cleared } "401": { $ref: "#/components/responses/SessionRequired" } /api/chat/reset: post: tags: [Chat] summary: Reset the conversation (alias of /api/clear) operationId: resetChat security: [{ sessionCookie: [] }] responses: "200": description: History cleared. content: application/json: example: { status: ok } "401": { $ref: "#/components/responses/SessionRequired" } # ── RAG passthrough ───────────────────────────────────────────────────── /api/query: post: tags: [RAG] summary: Query the retrieval backend directly description: > Sends the question to the retrieval backend and returns its response verbatim — typically an answer plus the retrieved `rag_context`. `question` is required (`query` is accepted as an alias). operationId: ragQuery security: [{ sessionCookie: [] }] requestBody: required: true content: application/json: schema: type: object required: [question] properties: question: { type: string } query: { type: string, description: Alias for `question`. } mode: { type: string, default: auto } example: question: What are the prohibited medications? responses: "200": description: Query result (response shape is defined by the retrieval backend). content: application/json: example: answer: "The protocol prohibits concurrent strong CYP3A4 inhibitors..." rag_context: "Protocol section 6.2: Prohibited concomitant medications..." "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/SessionRequired" } /api/index: post: tags: [RAG] summary: Index free text into RAG memory description: > Sends texts to the retrieval backend for indexing. Accepts a list of texts (a bare string is wrapped into a single-element list); the backend's response is returned verbatim. operationId: ragIndex security: [{ sessionCookie: [] }] requestBody: required: true content: application/json: schema: type: object properties: texts: oneOf: - { type: array, items: { type: string } } - { type: string } example: texts: - "Site visit note: parking validation available at the main desk." responses: "200": description: Index result from the retrieval backend. content: application/json: example: { indexed: 1 } "401": { $ref: "#/components/responses/SessionRequired" } # ── Voice & Avatar ────────────────────────────────────────────────────── /api/tts/pcm: post: tags: [Voice & Avatar] summary: Synthesize speech as raw PCM description: > Text-to-speech via the configured voice backend (Amazon Polly with `TTS_BACKEND=polly`). Returns base64-encoded raw 24 kHz 16-bit mono PCM. operationId: ttsPcm security: [{ sessionCookie: [] }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TTSRequest" } example: text: Your first infusion takes about 90 minutes. voice: en-US-JennyNeural speed: 1.0 responses: "200": description: Base64 PCM audio. content: application/json: example: audio: "UklGRiQAAABXQVZFZm10IBAAAAAB..." format: pcm-24khz-16bit-mono bytes: 172800 "400": { description: No text provided., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } "401": { $ref: "#/components/responses/SessionRequired" } "502": { description: Speech backend error., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } /api/avatar/token: get: tags: [Voice & Avatar] summary: Create a LiveAvatar session token operationId: getAvatarToken security: [{ sessionCookie: [] }] parameters: - name: avatar_id in: query required: false schema: type: string default: fc9c1f9f-bc99-4fd9-a6b2-8b4b5669a046 responses: "200": description: Short-lived LiveAvatar credentials for the browser. content: application/json: example: session_token: lat_9f3k... session_id: sess_01HZX... "401": { $ref: "#/components/responses/SessionRequired" } "500": { description: LiveAvatar API key not configured., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } "502": { description: LiveAvatar rejected the request., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } /api/speech/token: get: tags: [Voice & Avatar] summary: Get browser STT credentials description: > Returns short-lived credentials for browser speech-to-text. With `STT_BACKEND=transcribe` this is the Amazon Transcribe client configuration; the exact shape depends on the configured backend. operationId: getSpeechToken security: [{ sessionCookie: [] }] responses: "200": description: STT credentials. content: application/json: example: token: eyJhbGciOi... region: swedencentral "401": { $ref: "#/components/responses/SessionRequired" } "500": { description: Speech key not configured., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } # ── Trial (patient view) ──────────────────────────────────────────────── /api/trial: get: tags: [Trial (patient)] summary: Public info for the session's trial description: > The non-sensitive subset of the trial this consultation is about, for the patient UI header. No screening criteria — those stay behind the admin endpoints. Returns `{}` when the session has no resolvable trial. operationId: getCurrentTrial security: [{ sessionCookie: [] }] responses: "200": description: Public trial subset (or empty object). content: application/json: example: trial_id: acadabra-her2-nsclc trial_name: "ACADABRA: Acadabra in HER2-Mutant Advanced NSCLC" phase: Phase II protocol_number: ACA-2025-114 sponsor: Medimergent Oncology indication: HER2-mutant advanced non-small cell lung cancer description: Open-label study of Acadabra in previously treated HER2-mutant NSCLC. "401": { $ref: "#/components/responses/SessionRequired" } # ── Admin — Trials ────────────────────────────────────────────────────── /api/admin/trials: get: tags: [Admin — Trials] summary: List all trials (full records) description: > Returns complete `Trial` objects. (A `TrialSummary` variant of this route exists in the admin-panel router but is shadowed by this one, which registers first.) operationId: listTrials security: [{ basicAuth: [] }] responses: "200": description: All trials, drafts included. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Trial" } "401": { $ref: "#/components/responses/AdminRequired" } post: tags: [Admin — Trials] summary: Create an empty draft trial operationId: createTrial security: [{ basicAuth: [] }] requestBody: required: true content: application/json: schema: type: object required: [trial_id] additionalProperties: false properties: trial_id: { $ref: "#/components/schemas/TrialId" } example: trial_id: acadabra-her2-nsclc responses: "201": description: The new draft trial (all sections null). content: application/json: schema: { $ref: "#/components/schemas/Trial" } example: trial_id: acadabra-her2-nsclc status: draft "401": { $ref: "#/components/responses/AdminRequired" } "409": { description: Trial already exists., content: { application/json: { schema: { $ref: "#/components/schemas/Error" }, example: { detail: Trial already exists } } } } "422": { $ref: "#/components/responses/ValidationError" } /api/admin/trials/{trial_id}: get: tags: [Admin — Trials] summary: Get one trial operationId: getTrial security: [{ basicAuth: [] }] parameters: [{ $ref: "#/components/parameters/TrialIdPath" }] responses: "200": description: Full trial record. content: application/json: schema: { $ref: "#/components/schemas/Trial" } examples: fullTrial: $ref: "#/components/examples/TrialExample" "401": { $ref: "#/components/responses/AdminRequired" } "404": { $ref: "#/components/responses/TrialNotFound" } put: tags: [Admin — Trials] summary: Replace a whole trial record description: > Legacy console save: validates and stores the posted body as the whole trial (the `trial_id` path value wins over any in the body). Prefer the per-section `PUT .../sections/{section}` for targeted edits. operationId: updateTrial security: [{ basicAuth: [] }] parameters: [{ $ref: "#/components/parameters/TrialIdPath" }] requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/Trial" } responses: "200": description: Saved. content: application/json: example: { status: saved, trial_id: acadabra-her2-nsclc } "400": { $ref: "#/components/responses/BadRequest" } "401": { $ref: "#/components/responses/AdminRequired" } delete: tags: [Admin — Trials] summary: Delete a trial and everything attached to it description: > Clears the trial's RAG vectors (best effort), deletes its documents and infographics (files + records), then deletes the trial itself. operationId: deleteTrial security: [{ basicAuth: [] }] parameters: [{ $ref: "#/components/parameters/TrialIdPath" }] responses: "200": description: Deletion summary. content: application/json: example: status: deleted trial_id: acadabra-her2-nsclc trial_deleted: true documents_deleted: true infographics_deleted: true index_deleted: true "401": { $ref: "#/components/responses/AdminRequired" } "404": { $ref: "#/components/responses/TrialNotFound" } "500": { description: Trial record could not be deleted., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } /api/admin/trials/{trial_id}/sections/{section}: put: tags: [Admin — Trials] summary: Replace one section of a trial description: > Validates the body against the section's schema and stores it; sending `null` clears the section. Any section edit flips the trial back to `draft` (it must be re-activated to go live). operationId: replaceTrialSection security: [{ basicAuth: [] }] parameters: - $ref: "#/components/parameters/TrialIdPath" - name: section in: path required: true schema: { $ref: "#/components/schemas/SectionName" } requestBody: required: false description: The new section value (schema depends on `section`), or `null` to clear it. content: application/json: schema: oneOf: - $ref: "#/components/schemas/GeneralSection" - $ref: "#/components/schemas/StudySection" - $ref: "#/components/schemas/DosingSection" - $ref: "#/components/schemas/VisitScheduleSection" - $ref: "#/components/schemas/CriteriaSection" - $ref: "#/components/schemas/MedicationsSection" - $ref: "#/components/schemas/SideEffectsSection" - type: "null" examples: general: summary: Replace the general section value: trial_name: "ACADABRA: Acadabra in HER2-Mutant Advanced NSCLC" phase: Phase II sponsor: Medimergent Oncology clear: summary: Clear a section value: null responses: "200": description: The updated trial (now `draft`). content: application/json: schema: { $ref: "#/components/schemas/Trial" } "401": { $ref: "#/components/responses/AdminRequired" } "404": { $ref: "#/components/responses/TrialNotFound" } "422": { $ref: "#/components/responses/ValidationError" } /api/admin/trials/{trial_id}/extract: post: tags: [Admin — Trials, Admin — Documents] summary: Extract trial sections from uploaded documents (LLM) description: > Reads the trial's uploaded documents (all of them, or just `document_ids`), runs LLM extraction, applies the extracted sections to the trial, and saves it. Per-section validation problems come back in `warnings` rather than failing the whole call. operationId: extractTrial security: [{ basicAuth: [] }] parameters: [{ $ref: "#/components/parameters/TrialIdPath" }] requestBody: required: true content: application/json: schema: type: object additionalProperties: false properties: document_ids: type: [array, "null"] items: { type: string } description: Restrict extraction to these documents; omit/null = all. example: document_ids: null responses: "200": description: Updated trial plus what was applied. content: application/json: schema: { $ref: "#/components/schemas/ExtractionResponse" } example: trial: { trial_id: acadabra-her2-nsclc, status: draft } applied_sections: [general, dosing, criteria, side_effects] warnings: visit_schedule: ["day values must be unique"] source_document_ids: [0d5c2f6a-4a4b-4b62-9a4e-1c1b2f3a4d5e] "400": { description: No documents provided / none readable., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } "401": { $ref: "#/components/responses/AdminRequired" } "404": { description: Trial or a listed document not found., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } "502": { description: LLM extraction failed., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } /api/admin/trials/{trial_id}/index: post: tags: [Admin — Documents] summary: (Re-)index all trial documents into RAG description: > Reads every uploaded document, chunks and indexes them into the vector store, and updates per-document `status`/`indexed_at`/`error`. Indexing a trial with zero documents is a successful no-op. operationId: indexTrial security: [{ basicAuth: [] }] parameters: [{ $ref: "#/components/parameters/TrialIdPath" }] responses: "200": description: "Index outcome (also `status: failed` — this endpoint reports rather than errors)." content: application/json: schema: { $ref: "#/components/schemas/IndexStatusResponse" } example: status: complete documents_indexed: 3 total_documents: 3 last_indexed_at: "2026-07-13T14:02:11.512345+00:00" error: null "401": { $ref: "#/components/responses/AdminRequired" } "404": { $ref: "#/components/responses/TrialNotFound" } /api/admin/trials/{trial_id}/activate: post: tags: [Admin — Trials] summary: Index documents, then activate the trial description: > Runs the same indexing as `POST .../index`; only if it completes does the trial flip to `active` (visible to Carmen and eligible for invites). On indexing failure nothing changes and the index status is returned as the 500 error detail. operationId: activateTrial security: [{ basicAuth: [] }] parameters: [{ $ref: "#/components/parameters/TrialIdPath" }] responses: "200": description: The now-active trial. content: application/json: schema: { $ref: "#/components/schemas/Trial" } "401": { $ref: "#/components/responses/AdminRequired" } "404": { $ref: "#/components/responses/TrialNotFound" } "500": description: Indexing failed; `detail` is the IndexStatusResponse. content: application/json: example: detail: status: failed documents_indexed: 1 total_documents: 3 last_indexed_at: null error: Could not read every document /api/admin/trials/{trial_id}/deactivate: post: tags: [Admin — Trials] summary: Put a trial back into draft operationId: deactivateTrial security: [{ basicAuth: [] }] parameters: [{ $ref: "#/components/parameters/TrialIdPath" }] responses: "200": description: The trial, now `draft`. content: application/json: schema: { $ref: "#/components/schemas/Trial" } "401": { $ref: "#/components/responses/AdminRequired" } "404": { $ref: "#/components/responses/TrialNotFound" } /api/admin/trials/{trial_id}/criteria/{kind}: post: tags: [Admin — Trials] summary: Append one inclusion/exclusion criterion (legacy console) operationId: addCriterion security: [{ basicAuth: [] }] parameters: - $ref: "#/components/parameters/TrialIdPath" - $ref: "#/components/parameters/CriterionKind" requestBody: required: true content: application/json: schema: { $ref: "#/components/schemas/TrialCriterion" } example: id: inc-04 text: ECOG performance status 0–1 response_type: yes_no required: true responses: "200": description: Added; `count` is the new number of criteria of that kind. content: application/json: example: { status: added, count: 4 } "400": { description: "`kind` not 'inclusion'/'exclusion'.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } "401": { $ref: "#/components/responses/AdminRequired" } "404": { $ref: "#/components/responses/TrialNotFound" } /api/admin/trials/{trial_id}/criteria/{kind}/{criterion_id}: delete: tags: [Admin — Trials] summary: Delete one criterion (legacy console) operationId: deleteCriterion security: [{ basicAuth: [] }] parameters: - $ref: "#/components/parameters/TrialIdPath" - $ref: "#/components/parameters/CriterionKind" - name: criterion_id in: path required: true schema: { type: string } responses: "200": description: Outcome (200 even when the criterion didn't exist). content: application/json: examples: deleted: { value: { status: deleted, removed: true } } notFound: { value: { status: not_found, removed: false } } "400": { description: "`kind` not 'inclusion'/'exclusion'.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } "401": { $ref: "#/components/responses/AdminRequired" } # ── Admin — Documents ─────────────────────────────────────────────────── /api/admin/trials/{trial_id}/documents: post: tags: [Admin — Documents] summary: Upload a trial document description: > Multipart upload. The file is validated (name/size), stored (S3 or memory backend), and recorded with status `uploaded`. Uploading flips the trial back to `draft`. `document_type`, when given, must be one of the known types. operationId: uploadDocument security: [{ basicAuth: [] }] parameters: [{ $ref: "#/components/parameters/TrialIdPath" }] requestBody: required: true content: multipart/form-data: schema: type: object required: [file] properties: file: type: string format: binary document_type: type: [string, "null"] enum: - Prescribing information - Protocol synopsis - Informed consent template - Conversation guide - Eligibility criteria - null responses: "201": description: Stored document record. content: application/json: schema: { $ref: "#/components/schemas/DocumentResponse" } example: document_id: 0d5c2f6a-4a4b-4b62-9a4e-1c1b2f3a4d5e trial_id: acadabra-her2-nsclc filename: protocol-synopsis.pdf document_type: Protocol synopsis content_type: application/pdf size: 482133 uploaded_at: "2026-07-13T13:58:40.101112+00:00" status: uploaded indexed_at: null error: null "400": { description: "Invalid document type, filename, or size.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } "401": { $ref: "#/components/responses/AdminRequired" } "404": { $ref: "#/components/responses/TrialNotFound" } "500": { description: Storage failure., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } get: tags: [Admin — Documents] summary: List a trial's documents operationId: listDocuments security: [{ basicAuth: [] }] parameters: [{ $ref: "#/components/parameters/TrialIdPath" }] responses: "200": description: Documents, newest upload first. content: application/json: schema: type: array items: { $ref: "#/components/schemas/DocumentResponse" } "401": { $ref: "#/components/responses/AdminRequired" } "404": { $ref: "#/components/responses/TrialNotFound" } /api/admin/trials/{trial_id}/documents/{document_id}/content: get: tags: [Admin — Documents] summary: Download a document's original file operationId: downloadDocument security: [{ basicAuth: [] }] parameters: - $ref: "#/components/parameters/TrialIdPath" - $ref: "#/components/parameters/DocumentIdPath" responses: "200": description: Streamed file with its stored content type; `Content-Disposition attachment` with the original filename. content: application/octet-stream: schema: { type: string, format: binary } "401": { $ref: "#/components/responses/AdminRequired" } "404": { description: Trial or document not found., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } "500": { description: File retrieval failed., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } /api/admin/trials/{trial_id}/documents/{document_id}: delete: tags: [Admin — Documents] summary: Delete a document (file + record) description: Also flips the trial back to `draft`. operationId: deleteDocument security: [{ basicAuth: [] }] parameters: - $ref: "#/components/parameters/TrialIdPath" - $ref: "#/components/parameters/DocumentIdPath" responses: "200": description: Deleted. content: application/json: example: { status: deleted, document_id: 0d5c2f6a-4a4b-4b62-9a4e-1c1b2f3a4d5e } "401": { $ref: "#/components/responses/AdminRequired" } "404": { description: Trial or document not found., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } # ── Admin — Infographics ──────────────────────────────────────────────── /api/admin/trials/{trial_id}/infographics: post: tags: [Admin — Infographics] summary: Upload a static trial infographic image description: > Multipart upload for admin-managed static infographic images. The file must be JPEG or PNG and is stored separately from trial documents. Uploading an infographic does not change the trial's draft/active status. operationId: uploadInfographic security: [{ basicAuth: [] }] parameters: [{ $ref: "#/components/parameters/TrialIdPath" }] requestBody: required: true content: multipart/form-data: schema: type: object required: [file, title, caption, description] properties: file: type: string format: binary title: type: string minLength: 1 caption: type: string minLength: 1 description: type: string minLength: 1 responses: "201": description: Stored infographic record. content: application/json: schema: { $ref: "#/components/schemas/InfographicResponse" } example: infographic_id: 6c21b51d-9195-4c6f-88e8-fdbfdb4e2f0d trial_id: acadabra-her2-nsclc filename: treatment-schema.png title: Treatment Schema caption: Visit and infusion schedule at a glance. description: A graphical schedule of infusions, labs, and follow-up visits. content_type: image/png size: 148213 uploaded_at: "2026-07-20T13:58:40.101112+00:00" "400": { description: "Missing text field, invalid image type, filename, or size.", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } "401": { $ref: "#/components/responses/AdminRequired" } "404": { $ref: "#/components/responses/TrialNotFound" } "500": { description: Storage failure., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } get: tags: [Admin — Infographics] summary: List a trial's static infographics operationId: listInfographics security: [{ basicAuth: [] }] parameters: [{ $ref: "#/components/parameters/TrialIdPath" }] responses: "200": description: Infographics, newest upload first. content: application/json: schema: type: array items: { $ref: "#/components/schemas/InfographicResponse" } "401": { $ref: "#/components/responses/AdminRequired" } "404": { $ref: "#/components/responses/TrialNotFound" } /api/admin/trials/{trial_id}/infographics/{infographic_id}/content: get: tags: [Admin — Infographics] summary: Download a static infographic image operationId: downloadInfographic security: [{ basicAuth: [] }] parameters: - $ref: "#/components/parameters/TrialIdPath" - $ref: "#/components/parameters/InfographicIdPath" responses: "200": description: Streamed image with its stored content type. content: image/png: schema: { type: string, format: binary } image/jpeg: schema: { type: string, format: binary } "401": { $ref: "#/components/responses/AdminRequired" } "404": { description: Trial or infographic not found., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } "500": { description: File retrieval failed., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } /api/admin/trials/{trial_id}/infographics/{infographic_id}: delete: tags: [Admin — Infographics] summary: Delete a static infographic image (file + record) operationId: deleteInfographic security: [{ basicAuth: [] }] parameters: - $ref: "#/components/parameters/TrialIdPath" - $ref: "#/components/parameters/InfographicIdPath" responses: "200": description: Deleted. content: application/json: example: { status: deleted, infographic_id: 6c21b51d-9195-4c6f-88e8-fdbfdb4e2f0d } "401": { $ref: "#/components/responses/AdminRequired" } "404": { description: Trial or infographic not found., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } # ── Admin — Invites ───────────────────────────────────────────────────── /api/admin/invites: get: tags: [Admin — Invites] summary: List invite links operationId: listInvites security: [{ basicAuth: [] }] responses: "200": description: All invites, newest first, with their absolute redemption URL. content: application/json: schema: type: array items: { $ref: "#/components/schemas/Invite" } "401": { $ref: "#/components/responses/AdminRequired" } post: tags: [Admin — Invites] summary: Create a one-time invite link description: > Creates a 7-day, single-use invite bound to a trial. Hand the returned `url` to the patient; opening it and pressing start redeems the invite and creates their session. operationId: createInvite security: [{ basicAuth: [] }] requestBody: required: true content: application/json: schema: type: object required: [trial_id] properties: trial_id: { type: string, minLength: 1 } label: { type: string, maxLength: 200, default: "" } example: trial_id: acadabra-her2-nsclc label: "Pt. #0142 — Dr. Weber's clinic" responses: "201": description: The new invite. content: application/json: schema: { $ref: "#/components/schemas/Invite" } example: token: 9nUq3vX1kzT7pR4wY8sBdF2hJ6mL0aCeGiKoQuSwUyE trial_id: acadabra-her2-nsclc label: "Pt. #0142 — Dr. Weber's clinic" created_at: "2026-07-13T14:05:00.000000+00:00" expires_at: "2026-07-20T14:05:00.000000+00:00" ttl: 1753538700 used_at: null status: available url: "https://medimergent.example.com/invite/9nUq3vX1kzT7pR4wY8sBdF2hJ6mL0aCeGiKoQuSwUyE" "400": { description: Invalid trial ID., content: { application/json: { schema: { $ref: "#/components/schemas/Error" }, example: { detail: Invalid trial ID } } } } "401": { $ref: "#/components/responses/AdminRequired" } # ── Admin — Conversations & Calls ─────────────────────────────────────── /api/calls: get: tags: [Admin — Conversations & Calls] summary: List finalized calls description: The 50 most recent call records (created by `POST /api/conversation/end`). operationId: listCalls security: [{ basicAuth: [] }] responses: "200": description: Recent calls, newest first. content: application/json: schema: type: array items: { $ref: "#/components/schemas/CallRecord" } "401": { $ref: "#/components/responses/AdminRequired" } /api/conversations: get: tags: [Admin — Conversations & Calls] summary: List conversation summaries description: The 50 most recent conversations with stored chat history (live and finished). operationId: listConversations security: [{ basicAuth: [] }] responses: "200": description: Summaries, newest first. content: application/json: schema: type: array items: { $ref: "#/components/schemas/ChatSummary" } example: - conversation_id: C-7Q2M4KP9XT1V trial_id: acadabra-her2-nsclc start_time: "2026-07-13T13:40:02.000000+00:00" updated_at: "2026-07-13T13:52:47.000000+00:00" message_count: 18 build_tag: v1.4.2 "401": { $ref: "#/components/responses/AdminRequired" } /api/conversations/{conversation_id}: get: tags: [Admin — Conversations & Calls] summary: Get one conversation's transcript operationId: getConversation security: [{ basicAuth: [] }] parameters: - name: conversation_id in: path required: true schema: { type: string } example: C-7Q2M4KP9XT1V responses: "200": description: Full message list. content: application/json: schema: type: object properties: messages: type: array items: { $ref: "#/components/schemas/ChatMessage" } "401": { $ref: "#/components/responses/AdminRequired" } "404": { description: No messages stored for this ID., content: { application/json: { schema: { $ref: "#/components/schemas/Error" }, example: { detail: Conversation not found } } } } # ── Admin — Maintenance ───────────────────────────────────────────────── /api/admin/reset-vectors: post: tags: [Admin — Maintenance] summary: "DESTRUCTIVE: wipe ALL RAG vectors (keep trials, files, logs)" description: | Clears every vector from the store and resets all `indexed` documents back to `uploaded`. Trial metadata, S3 files, conversations, and call records are preserved. Afterwards each trial must be re-indexed (`POST /api/admin/trials/{trial_id}/index` or the admin panel). operationId: resetVectors security: [{ basicAuth: [] }] responses: "200": description: Reset summary. content: application/json: example: status: reset_complete scope: vectors_only vectors_cleared: { cleared: true } documents_reset: 7 trials_preserved: 3 next_step: "Re-index each trial via admin panel or POST /api/admin/trials/{id}/index" "401": { $ref: "#/components/responses/AdminRequired" } "500": { description: Vector store could not be cleared., content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } } /api/admin/reset-all: post: tags: [Admin — Maintenance] summary: "DESTRUCTIVE: wipe vectors + trials + documents + files" description: > True nuclear reset: deletes all vectors, all trial metadata, all document records, all infographic records, and all stored files. Only conversation logs and call transcripts survive (audit trail). There is no undo. operationId: resetAll security: [{ basicAuth: [] }] responses: "200": description: Reset summary. content: application/json: example: status: reset_complete scope: everything trials_deleted: 3 documents_deleted: 7 infographics_deleted: 4 vectors_cleared: { cleared: true } next_step: "Start fresh — create new trials via admin panel" "401": { $ref: "#/components/responses/AdminRequired" } components: securitySchemes: sessionCookie: type: apiKey in: cookie name: carmen_session description: > Opaque patient-session ID (7-day TTL), set by redeeming an invite (`POST /invite/{token}/redeem`) or an admin test session. Missing or expired ⇒ 401 "Active Carmen session required". basicAuth: type: http scheme: basic description: Username is always `admin`; password comes from the `ADMIN_PASSWORD` env var. parameters: InviteToken: name: token in: path required: true description: One-time invite token (URL-safe, from the invite's `url`). schema: { type: string } TrialIdPath: name: trial_id in: path required: true schema: { $ref: "#/components/schemas/TrialId" } example: acadabra-her2-nsclc DocumentIdPath: name: document_id in: path required: true schema: { type: string, format: uuid } example: 0d5c2f6a-4a4b-4b62-9a4e-1c1b2f3a4d5e InfographicIdPath: name: infographic_id in: path required: true schema: { type: string, format: uuid } example: 6c21b51d-9195-4c6f-88e8-fdbfdb4e2f0d CriterionKind: name: kind in: path required: true schema: type: string enum: [inclusion, exclusion] responses: SessionRequired: description: No valid patient session cookie. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { detail: Active Carmen session required } AdminRequired: description: Missing or invalid admin credentials. headers: WWW-Authenticate: schema: { type: string } description: "`Basic realm='Carmen Admin'`" content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { detail: Admin credentials required } TrialNotFound: description: Trial not found. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { detail: Trial not found } BadRequest: description: Malformed or missing input. content: application/json: schema: { $ref: "#/components/schemas/Error" } example: { detail: "Provide a 'message' field" } ValidationError: description: Body failed schema validation (FastAPI/pydantic error list). content: application/json: example: detail: - loc: [body, trial_id] msg: String should match pattern '^[A-Za-z0-9][A-Za-z0-9._-]*$' type: string_pattern_mismatch examples: TrialExample: summary: A fully onboarded trial value: trial_id: acadabra-her2-nsclc status: active general: trial_name: "ACADABRA: Acadabra in HER2-Mutant Advanced NSCLC" phase: Phase II protocol_number: ACA-2025-114 sponsor: Medimergent Oncology managed_by: Fraunhofer Clinical Site Network description: Open-label study of Acadabra in previously treated HER2-mutant NSCLC. study: drug_name: Acadabra indication: HER2-mutant advanced non-small cell lung cancer manufacturer: Medimergent Oncology target_enrollment: 120 study_duration_months: 24 dosing: dose: 5.4 mg/kg route: IV infusion schedule: Every 3 weeks total_doses: 18 first_infusion_duration_min: 90 subsequent_infusion_duration_min: 60 observation_period_min: 30 visit_schedule: screening: [Medical history, HER2 mutation confirmation] baseline: [CT scan, ECG, Blood panel] infusion_days: [1, 22, 43] lab_days: [1, 8, 22] survey_days: [8, 29] ct_scan_day: 63 criteria: inclusion: - id: inc-01 text: Histologically confirmed HER2-mutant NSCLC response_type: yes_no required: true - id: inc-02 text: Age 18 or older response_type: yes_no required: true exclusion: - id: exc-01 text: Prior HER2-directed antibody-drug conjugate therapy response_type: yes_no required: true fail_message: Prior HER2 ADC therapy is exclusionary for this study. medications: prohibited: [Strong CYP3A4 inhibitors, Live vaccines] side_effects: items: [Nausea, Fatigue, Decreased appetite, Infusion-related reaction] schemas: Error: type: object description: FastAPI error envelope. properties: detail: description: Human-readable message, or a validation-error list/object. required: [detail] example: { detail: Trial not found } TrialId: type: string minLength: 1 maxLength: 100 pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*$" description: Stable trial identifier (letters, digits, `.`, `_`, `-`; must start alphanumeric). example: acadabra-her2-nsclc SectionName: type: string enum: [general, study, dosing, visit_schedule, criteria, medications, side_effects] Trial: type: object description: > Canonical trial record. Sections are independently editable and each may be null while the trial is being onboarded. All schemas are strict: unknown fields are rejected. additionalProperties: false required: [trial_id] properties: trial_id: { $ref: "#/components/schemas/TrialId" } status: type: string enum: [draft, active] default: draft description: Only `active` trials are visible to Carmen and to patient invites. general: { oneOf: [{ $ref: "#/components/schemas/GeneralSection" }, { type: "null" }] } study: { oneOf: [{ $ref: "#/components/schemas/StudySection" }, { type: "null" }] } dosing: { oneOf: [{ $ref: "#/components/schemas/DosingSection" }, { type: "null" }] } visit_schedule: { oneOf: [{ $ref: "#/components/schemas/VisitScheduleSection" }, { type: "null" }] } criteria: { oneOf: [{ $ref: "#/components/schemas/CriteriaSection" }, { type: "null" }] } medications: { oneOf: [{ $ref: "#/components/schemas/MedicationsSection" }, { type: "null" }] } side_effects: { oneOf: [{ $ref: "#/components/schemas/SideEffectsSection" }, { type: "null" }] } GeneralSection: type: object additionalProperties: false required: [trial_name] properties: trial_name: { type: string, minLength: 1 } phase: type: [string, "null"] enum: [Preclinical, Phase 0, Phase I, Phase II, Phase III, Phase IV, null] protocol_number: { type: [string, "null"] } sponsor: { type: [string, "null"] } managed_by: { type: [string, "null"] } description: { type: [string, "null"] } StudySection: type: object additionalProperties: false properties: drug_name: { type: [string, "null"] } indication: { type: [string, "null"] } manufacturer: { type: [string, "null"] } target_enrollment: { type: [integer, "null"], exclusiveMinimum: 0 } study_duration_months: { type: [integer, "null"], exclusiveMinimum: 0 } DosingSection: type: object additionalProperties: false properties: dose: { type: [string, "null"], example: 5.4 mg/kg } route: { type: [string, "null"], example: IV infusion } schedule: { type: [string, "null"], example: Every 3 weeks } total_doses: { type: [integer, "null"], exclusiveMinimum: 0 } first_infusion_duration_min: { type: [integer, "null"], exclusiveMinimum: 0 } subsequent_infusion_duration_min: { type: [integer, "null"], exclusiveMinimum: 0 } observation_period_min: { type: [integer, "null"], exclusiveMinimum: 0 } VisitScheduleSection: type: object additionalProperties: false description: Day numbers are 1-based study days; each list must be unique and positive. properties: screening: { type: array, items: { type: string, minLength: 1 } } baseline: { type: array, items: { type: string, minLength: 1 } } infusion_days: { type: array, items: { type: integer, exclusiveMinimum: 0 } } lab_days: { type: array, items: { type: integer, exclusiveMinimum: 0 } } survey_days: { type: array, items: { type: integer, exclusiveMinimum: 0 } } ct_scan_day: { type: [integer, "null"], exclusiveMinimum: 0 } TrialCriterion: type: object additionalProperties: false required: [id, text] properties: id: { type: string, minLength: 1, description: Unique across BOTH inclusion and exclusion lists. } text: { type: string, minLength: 1 } category: { type: [string, "null"] } question: { type: [string, "null"], description: How Carmen asks this during screening. } response_type: type: [string, "null"] enum: [yes_no, numeric, choice, null] required: { type: boolean, default: false } fail_message: { type: [string, "null"], description: Shown/said when the answer fails the criterion. } CriteriaSection: type: object additionalProperties: false properties: inclusion: { type: array, items: { $ref: "#/components/schemas/TrialCriterion" } } exclusion: { type: array, items: { $ref: "#/components/schemas/TrialCriterion" } } MedicationsSection: type: object additionalProperties: false properties: prohibited: { type: array, items: { type: string, minLength: 1 } } SideEffectsSection: type: object additionalProperties: false properties: items: { type: array, items: { type: string, minLength: 1 } } TrialSummary: type: object description: Compact listing row (used by the admin panel UI). properties: trial_id: { type: string } trial_name: { type: string, description: Falls back to `trial_id` when the general section is empty. } phase: { type: [string, "null"] } sponsor: { type: [string, "null"] } status: { type: string, enum: [draft, active] } DocumentResponse: type: object properties: document_id: { type: string, format: uuid } trial_id: { type: string } filename: { type: string } document_type: type: [string, "null"] enum: - Prescribing information - Protocol synopsis - Informed consent template - Conversation guide - Eligibility criteria - null content_type: { type: string, example: application/pdf } size: { type: integer, description: Bytes. } uploaded_at: { type: string, format: date-time } status: type: string enum: [uploaded, indexed, failed] indexed_at: { type: [string, "null"], format: date-time } error: { type: [string, "null"], description: Why indexing failed, if it did. } InfographicResponse: type: object properties: infographic_id: { type: string, format: uuid } trial_id: { type: string } filename: { type: string } title: { type: string } caption: { type: string } description: { type: string } content_type: { type: string, enum: [image/jpeg, image/png] } size: { type: integer, description: Bytes. } uploaded_at: { type: string, format: date-time } ExtractionResponse: type: object properties: trial: { $ref: "#/components/schemas/Trial" } applied_sections: type: array items: { $ref: "#/components/schemas/SectionName" } warnings: type: object additionalProperties: type: array items: { type: string } description: Per-section (or `documents`) non-fatal problems. source_document_ids: type: array items: { type: string } IndexStatusResponse: type: object properties: status: { type: string, enum: [complete, failed] } documents_indexed: { type: integer } total_documents: { type: integer } last_indexed_at: { type: [string, "null"], format: date-time } error: { type: [string, "null"] } Invite: type: object description: One-time, 7-day patient invite link. properties: token: { type: string, description: URL-safe secret; the last path segment of `url`. } trial_id: { type: string } label: { type: string, description: Free-text note for staff (never shown to the patient). } created_at: { type: string, format: date-time } expires_at: { type: string, format: date-time } ttl: { type: integer, description: Expiry as a Unix timestamp (storage TTL). } used_at: { type: [string, "null"], format: date-time } status: { type: string, enum: [available, used, expired] } url: { type: string, format: uri, description: Absolute redemption URL to hand to the patient. } ChatRequest: type: object required: [message] properties: message: { type: string, description: The patient's utterance. Must be non-blank. } response_style: type: string enum: [normal, brief, verbose] default: normal description: "`brief` hard-caps replies (~45 spoken words) for voice UIs." include_meta: type: boolean default: false description: Emit response stats (readability grade, latency, sentence count). ChatMessage: type: object properties: role: { type: string, enum: [user, assistant] } content: type: string description: > Display text. Assistant turns the patient cut off end with the literal marker `(interrupted)`. ChatSummary: type: object description: Queryable row per conversation (the dashboard list). properties: conversation_id: { type: string, example: C-7Q2M4KP9XT1V } trial_id: { type: [string, "null"] } start_time: { type: string, format: date-time } updated_at: { type: string, format: date-time } message_count: { type: integer } build_tag: { type: [string, "null"], description: Software version the conversation started on. } CallAnalysis: type: object properties: summary: { type: string } call_reason: { type: string, example: trial_inquiry } sentiment: { type: string, example: neutral } eligibility_result: { type: string, example: not_screened } resolution: { type: string, example: resolved } caller_name: { type: [string, "null"] } trial_discussed: { type: [string, "null"] } CallRecord: type: object description: Finalized consultation, written by `POST /api/conversation/end`. properties: call_id: { type: string, description: Same as the conversation ID., example: C-7Q2M4KP9XT1V } type: { type: string, example: video_consultation } started_at: { type: string, format: date-time } ended_at: { type: string, format: date-time } duration: { type: number, description: Seconds. } transcript: { type: string, description: "`ROLE: content` lines joined by newlines." } message_count: { type: integer } messages: type: array items: { $ref: "#/components/schemas/ChatMessage" } analysis: { $ref: "#/components/schemas/CallAnalysis" } metadata: type: object description: Session context (`session_id`, `invite_id`, `trial_id`). TTSRequest: type: object required: [text] properties: text: { type: string } voice: { type: string, default: en-US-JennyNeural } speed: { type: number, default: 1.0, description: "1.0 = normal; mapped to a prosody rate offset." }