{
  "openapi": "3.1.0",
  "info": {
    "title": "Omnimed AI API",
    "version": "2.0.0",
    "summary": "Turn a medical dictation into a structured, template-filled report in one request.",
    "description": "Omnimed AI transcribes a doctor's dictation, detects which study it belongs to, matches it against the medical center's report templates, and returns the finished report as HTML. It also writes addenda for reports already signed in the host system.\n\n## When to use this API\n\nReach for Omnimed AI when you need to produce a structured radiology or diagnostic-imaging report from spoken or written dictation, inside your own RIS, HIS, PACS viewer or reporting tool. Typical jobs:\n\n- Generate a report from an audio dictation (`POST /api/omni-ai` with `file`).\n- Generate a report from dictation you already have as text (`POST /api/omni-ai` with `transcription_text`).\n- Force the report onto a specific template you already know (`template_html`), skipping study detection.\n- Apply a spoken correction to a report you generated earlier (`POST /api/omni-ai/refine`).\n- Add a dictated addendum to a report the doctor already signed in your system (`POST /api/omni-ai/addendum`); only the dictated text comes back, the signed report is never changed.\n- Embed the recording Widget in a web page: your backend exchanges its Partner Credential for a per-doctor Widget token (`POST /api/widget/token`) and the Widget at `https://api.omniai.com.ar/static/widget/v2/omni-widget.min.js` calls this same API with it.\n\n## Authentication\n\n- **Partner Credential** (`partnerCredential`, `Authorization: Bearer omp_...`): one secret per Integration Partner, enabled for a list of centers, issued by Omnimed AI (santiago@omniai.com.ar). Server-to-server only; it must never reach a browser. Every call names the center (`medical_center_id`) and the **declared user** (`username`, the doctor's username in that center, vouched for by the Partner and never logged in with Omnimed AI).\n- **Widget token** (`widgetToken`, `Authorization: Bearer omw_...`): short-lived (1 hour) token bound to one center and one declared user, obtained with `POST /api/widget/token`. With it, `medical_center_id` and `username` in the form are ignored. A token cannot be revoked on its own; revoking or rotating the Partner Credential invalidates the tokens it issued in under 60 seconds.\n- **No credential** (legacy, v1 behaviour): only on `generateReport` and `refineReport`, and only for centers that already integrated without one, while they migrate. New centers must use a Partner Credential; uncredentialed access is closed center by center with prior notice.\n\nA Bearer whose value starts with neither `omp_` nor `omw_` is ignored: the call is treated as carrying no credential.\n\n## Versioning and deprecation\n\nThis document describes v2 of the API contract (`info.version`). The paths are unversioned and shared with v1; v2 adds authentication, the Widget token exchange and addenda, and leaves v1 parameters and responses unchanged. v1 (`https://www.omniai.com.ar/openapi.json`) stays available for the centers already using it; v2 is recommended for every new integration. Breaking changes, including closing the uncredentialed path for a given center, are announced by email to provisioned integration partners at least 90 days before they ship. Additive changes (new optional parameters, new response fields, new `detail.code` values) can ship at any time, so parse responses tolerantly.\n\n## Errors\n\nGeneral errors (input, center, processing) use `{ \"success\": false, \"error\": \"...\", \"error_code\": 400, \"timestamp\": \"...\" }` (the `ErrorResponse` schema). `error_code` usually mirrors the HTTP status; branch on the status. `1011` (the center has no templates, status 422) is the specific code worth knowing. A failed generation is a 500 (`\"error\": \"Report generation failed: ...\"`), including audio that transcribes to empty text; on refine and addendum an unavailable model is a 503.\n\nCredential and token errors use `{ \"detail\": { \"code\": \"...\", \"message\": \"...\" } }` (the `CredentialErrorResponse` schema). Branch on `detail.code`: `missing_partner_credential`, `invalid_partner_credential`, `partner_credential_revoked`, `widget_token_expired`, `invalid_widget_token` (401); `center_not_enabled` (403); `widget_token_not_configured`, `partner_lookup_unavailable` (503, retry).\n\nA missing or mistyped required field returns FastAPI's validation shape, `{ \"detail\": [ ... ] }` with status 422 (`ValidationErrorResponse`).\n\n## Limits and operations\n\n- Audio up to 100 MB (minimum 1KB); `transcription_text` 50KB; `metadata` 10KB; `template_html` and `signed_report` 100KB.\n- Set a client timeout of at least 120 seconds (the server's timeout).\n- There is no published per-partner rate limit today. Retry 5xx responses with exponential backoff; do not retry 4xx unchanged.\n- A call that fails consumes no credit; a successful call that is repeated consumes another. Refinements and addenda consume none.\n- Request one Widget token per doctor and cache it until `expires_at`.",
    "termsOfService": "https://www.omniai.com.ar/terms-of-service",
    "contact": {
      "name": "Omnimed AI - integraciones",
      "email": "santiago@omniai.com.ar",
      "url": "https://www.omniai.com.ar/api-documentation/radiologia"
    }
  },
  "externalDocs": {
    "description": "Omnimed AI integration guide v2 (Widget v2 + direct API)",
    "url": "https://www.omniai.com.ar/api-documentation/radiologia"
  },
  "servers": [
    {
      "url": "https://api.omniai.com.ar",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Reports",
      "description": "Generate and refine structured medical reports, and write addenda for signed reports."
    },
    {
      "name": "Widget",
      "description": "Exchange a Partner Credential for a per-doctor Widget token."
    },
    {
      "name": "Service",
      "description": "Connectivity and status checks."
    }
  ],
  "paths": {
    "/api/omni-ai": {
      "post": {
        "operationId": "generateReport",
        "tags": [
          "Reports"
        ],
        "security": [
          {
            "partnerCredential": []
          },
          {
            "widgetToken": []
          },
          {}
        ],
        "summary": "Generate a structured report from a dictation",
        "description": "Accepts an audio dictation (`file`) or the dictation already transcribed (`transcription_text`) and returns the structured report as HTML. `data.reports` always has one entry: when the doctor dictates several studies, their reports come concatenated in that entry and `data.metadata.is_multi_study` is true.\n\nWithout `template_html`, the study is detected automatically from the dictation and the template is chosen from the center's stored templates; you do not need to send the study name. With `template_html`, detection and matching are skipped and the report deterministically follows the template you sent, always producing exactly one report.\n\nAudio always wins: if both `file` and `transcription_text` are sent, the audio is transcribed and the text is ignored, with no fallback if transcription fails. A blank value in either field is treated as absent, and sending neither returns 400.\n\nSend `username` (the declared user) to choose among that doctor's visible templates (center-wide + assigned); without it only the center-wide templates are considered. Send the ordered study in `dicom_metadata` to get laterality / modality / region mismatch warnings in `data.warnings`; they never change or block the report.\n\nAuthenticate with a Partner Credential (`Authorization: Bearer omp_...`) plus `medical_center_id` and `username`, or with a Widget token (`Authorization: Bearer omw_...`), which names the center and the doctor itself. The empty security requirement (`{}`, no credential) is legacy v1 behaviour, accepted only for centers that already integrated without a credential, while they migrate.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/GenerateRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Report(s) generated.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GenerateResponse"
                }
              }
            }
          },
          "400": {
            "description": "Neither `file` nor `transcription_text` was sent; the audio is under 1KB, its extension does not match its content, or its format is unsupported; `metadata` is not valid JSON or exceeds 10KB; `transcription_text` exceeds 50KB; or `template_html` exceeds 100KB or is empty after sanitisation.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "The Partner Credential or Widget token was not accepted: `invalid_partner_credential`, `partner_credential_revoked`, `widget_token_expired` or `invalid_widget_token`. The Widget renews an expired token on its own through `getToken()`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CredentialErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "`center_not_enabled` (`CredentialErrorResponse`): the credential is not enabled for this `medical_center_id`. Or (`ErrorResponse`): the center is disabled.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    },
                    {
                      "$ref": "#/components/schemas/CredentialErrorResponse"
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "description": "The center has no templates configured (`error_code` 1011). Contact Omnimed AI. Or (`ValidationErrorResponse`) `medical_center_id` is missing and the call carries no Widget token.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    },
                    {
                      "$ref": "#/components/schemas/ValidationErrorResponse"
                    }
                  ]
                }
              }
            }
          },
          "500": {
            "description": "Transcription or report generation failed (`\"error\": \"Report generation failed: ...\"`), including audio that transcribes to empty text (e.g. a muted microphone). Retry with exponential backoff.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "`partner_lookup_unavailable` / `widget_token_not_configured`: the credential or token could not be verified right now. Retry with exponential backoff.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CredentialErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/omni-ai/refine": {
      "post": {
        "operationId": "refineReport",
        "tags": [
          "Reports"
        ],
        "security": [
          {
            "partnerCredential": []
          },
          {
            "widgetToken": []
          },
          {}
        ],
        "summary": "Apply a dictated correction to an existing report",
        "description": "Takes a report previously returned by `generateReport` plus a dictated correction (\"cambiar menisco interno por menisco externo\") and returns the same report with only the targeted element changed — plus the Conclusion when the change affects it. The whole report is never regenerated, and refinement is not billed.\n\n`data.report_html` is the full corrected report, ready to use. `data.patch` describes the exact regions that changed if you prefer to splice them into your own editor. To chain refinements, feed the returned `report_html` into the next call.\n\nAuthenticate with a Partner Credential (`Authorization: Bearer omp_...`) plus `medical_center_id`, or with a Widget token (`Authorization: Bearer omw_...`), which names the center and the doctor itself. The empty security requirement (`{}`, no credential) is legacy v1 behaviour, accepted only for centers that already integrated without a credential, while they migrate.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/RefineRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Report refined.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RefineResponse"
                }
              }
            }
          },
          "400": {
            "description": "Blank `report_html`; missing both `file` and `transcription_text`; invalid audio (under 1KB, extension not matching content, unsupported format); `transcription_text` over 50KB; or `template_html` over 100KB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "The Partner Credential or Widget token was not accepted: `invalid_partner_credential`, `partner_credential_revoked`, `widget_token_expired` or `invalid_widget_token`. The Widget renews an expired token on its own through `getToken()`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CredentialErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "`center_not_enabled` (`CredentialErrorResponse`): the credential is not enabled for this `medical_center_id`. Or (`ErrorResponse`): the center is disabled.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    },
                    {
                      "$ref": "#/components/schemas/CredentialErrorResponse"
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "description": "`report_html` is missing, or `medical_center_id` is missing and the call carries no Widget token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationErrorResponse"
                }
              }
            }
          },
          "500": {
            "description": "Transcription or refinement failed, including audio that transcribes to empty text. Retry with exponential backoff.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "The refinement model is unavailable right now (`ErrorResponse`, `error_code` 503), or the credential or token could not be verified right now (`CredentialErrorResponse`, `partner_lookup_unavailable` / `widget_token_not_configured`). Retry with exponential backoff.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    },
                    {
                      "$ref": "#/components/schemas/CredentialErrorResponse"
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/api/omni-ai/addendum": {
      "post": {
        "operationId": "createAddendum",
        "tags": [
          "Reports"
        ],
        "security": [
          {
            "partnerCredential": []
          },
          {
            "widgetToken": []
          }
        ],
        "summary": "Write an addendum for a report already signed in the host system",
        "description": "The doctor already signed the report in your system and dictates something to add to it (a late finding, or comparative findings when the patient brings prior studies). Returns **only what was dictated**, as plain paragraphs with no title, in the center's report language. Your system adds its own heading (\"ADDENDUM\"), date and signature and appends it; the signed report is never changed.\n\nNo template is matched and there is no study detection, verification or mismatch check. Addenda are not billed. To correct one, dictate it again: there is no refine for addenda. `signed_report` is optional, read-only context used only to name what the doctor refers to.\n\nAuthenticate with a Partner Credential (`Authorization: Bearer omp_...`) plus `medical_center_id` and `username`, or with a Widget token (`Authorization: Bearer omw_...`). From the embedded Widget v2 the same flow is `OmniWidget.openAddendum({ signedReport, externalStudyId })` plus the `omniai:addendum` event.",
        "requestBody": {
          "required": true,
          "content": {
            "multipart/form-data": {
              "schema": {
                "$ref": "#/components/schemas/AddendumRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Addendum written.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AddendumResponse"
                }
              }
            }
          },
          "400": {
            "description": "Neither `file` nor `transcription_text` was sent; invalid audio (under 1KB, extension not matching content, unsupported format); `transcription_text` over 50KB; or `signed_report` over 100KB.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "401": {
            "description": "The Partner Credential or Widget token was not accepted: `invalid_partner_credential`, `partner_credential_revoked`, `widget_token_expired` or `invalid_widget_token`. The Widget renews an expired token on its own through `getToken()`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CredentialErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "`center_not_enabled` (`CredentialErrorResponse`): the credential is not enabled for this `medical_center_id`. Or (`ErrorResponse`): the center is disabled.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    },
                    {
                      "$ref": "#/components/schemas/CredentialErrorResponse"
                    }
                  ]
                }
              }
            }
          },
          "422": {
            "description": "`medical_center_id` is missing and the call carries no Widget token.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ValidationErrorResponse"
                }
              }
            }
          },
          "500": {
            "description": "Transcription or addendum generation failed, including audio that transcribes to empty text. Retry with exponential backoff.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "502": {
            "description": "The model returned an empty addendum. Dictate it again.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            }
          },
          "503": {
            "description": "The model is unavailable right now (`ErrorResponse`, `error_code` 503), or the credential or token could not be verified right now (`CredentialErrorResponse`, `partner_lookup_unavailable` / `widget_token_not_configured`). Retry with exponential backoff.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    },
                    {
                      "$ref": "#/components/schemas/CredentialErrorResponse"
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/api/widget/token": {
      "post": {
        "operationId": "createWidgetToken",
        "tags": [
          "Widget"
        ],
        "summary": "Exchange the Partner Credential for a per-doctor Widget token",
        "description": "Server-to-server. Your backend sends its Partner Credential as a Bearer and names the center and the declared user; it gets back a Widget token valid for about one hour, bound to that center and that doctor. Hand the token to the page (`OmniWidget.init({ getToken })`); the Widget sends it as `Authorization: Bearer omw_...` on every call and asks `getToken()` for a new one when it expires.\n\nDecide `username` from your own session, never from something the browser sends: a doctor must not be able to request a token in a colleague's name. For a different doctor, request a different token.",
        "security": [
          {
            "partnerCredential": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WidgetTokenRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Widget token issued.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WidgetTokenResponse"
                }
              }
            }
          },
          "401": {
            "description": "`missing_partner_credential` (no `Authorization: Bearer omp_...`), `invalid_partner_credential` or `partner_credential_revoked`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CredentialErrorResponse"
                }
              }
            }
          },
          "403": {
            "description": "`center_not_enabled`: the credential is not enabled for this `medical_center_id`. Contact Omnimed AI.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CredentialErrorResponse"
                }
              }
            }
          },
          "422": {
            "description": "`medical_center_id` or `username` is missing or empty (`ValidationErrorResponse`), or `username` is only whitespace (`ErrorResponse`).",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/ValidationErrorResponse"
                    },
                    {
                      "$ref": "#/components/schemas/ErrorResponse"
                    }
                  ]
                }
              }
            }
          },
          "503": {
            "description": "`widget_token_not_configured` or `partner_lookup_unavailable`: a problem on our side. Retry with exponential backoff.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CredentialErrorResponse"
                }
              }
            }
          }
        }
      }
    },
    "/api/health": {
      "get": {
        "operationId": "getHealth",
        "tags": [
          "Service"
        ],
        "summary": "Check API connectivity",
        "description": "Returns the status of the API and its dependent services. Use it to verify connectivity from your backend before going live.",
        "responses": {
          "200": {
            "description": "Service status.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/HealthResponse"
                }
              }
            }
          }
        },
        "security": []
      }
    }
  },
  "components": {
    "securitySchemes": {
      "partnerCredential": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "omp_...",
        "description": "Partner Credential issued by Omnimed AI to an integration partner, enabled for a list of centers. Server-to-server only; never send it from a browser."
      },
      "widgetToken": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "omw_...",
        "description": "Widget token from `POST /api/widget/token`: valid for about one hour, bound to one center and one declared user. The center and doctor come from the token; form fields naming them are ignored. It cannot be revoked on its own; revoking the Partner Credential that issued it invalidates it in under 60 seconds."
      }
    },
    "schemas": {
      "GenerateRequest": {
        "type": "object",
        "description": "Multipart form. Exactly one of `file` or `transcription_text` is required. `medical_center_id` is required unless the call carries a Widget token.",
        "required": [],
        "properties": {
          "file": {
            "type": "string",
            "format": "binary",
            "description": "Audio dictation, up to 100 MB (minimum 1KB). Supported: wav, mp3, m4a, ogg, flac, webm; the extension must match the content. Required unless `transcription_text` is sent; if both are sent the audio wins."
          },
          "transcription_text": {
            "type": "string",
            "maxLength": 51200,
            "description": "The dictation as plain text, used instead of audio. Max 50KB. Skips the transcription step; study detection, template matching and generation behave identically."
          },
          "medical_center_id": {
            "type": "string",
            "description": "Center identifier provisioned by Omnimed AI; with a Partner Credential it must be a center the credential is enabled for. Required unless a Widget token is sent, in which case it is ignored."
          },
          "user_id": {
            "type": "string",
            "description": "Doctor or user identifier, recorded for analytics."
          },
          "username": {
            "type": "string",
            "description": "Declared user: the doctor's username in the center (same login as the desktop app), vouched for by the Partner. The template is chosen from that doctor's visible templates (center-wide + assigned directly or through a group). Absent or unknown to the center: center-wide templates only; the call is still tracked under the declared name. Ignored when `template_html` is sent. Ignored with a Widget token, which carries its own."
          },
          "language": {
            "type": "string",
            "default": "spa",
            "description": "Does not change the language: dictation and report use the language of the center (`medical_center_id`). Echoed back as `language_detected` only when the input is `transcription_text`."
          },
          "metadata": {
            "type": "string",
            "format": "json",
            "maxLength": 10240,
            "description": "Free-form JSON metadata, max 10KB."
          },
          "dicom_metadata": {
            "type": "string",
            "format": "json",
            "description": "Study Context from the order / DICOM headers, as a JSON object (see `DicomMetadata`). Compared with the dictation to produce `data.warnings`; never changes the report.",
            "contentMediaType": "application/json"
          },
          "template_html": {
            "type": "string",
            "maxLength": 102400,
            "description": "Preselected template. When present, study detection and template matching are skipped and the report follows this template exactly, always yielding a single report. Sanitised server-side; max 100KB."
          },
          "template_name": {
            "type": "string",
            "description": "Label for the preselected template. Echoed back as `study_name` and `template_used`."
          },
          "include_template": {
            "type": "boolean",
            "default": false,
            "description": "Return the sanitised template the server actually used, for diff highlighting."
          },
          "include_rtf": {
            "type": "boolean",
            "default": false,
            "description": "Also return an RTF rendering of each report."
          }
        }
      },
      "RefineRequest": {
        "type": "object",
        "description": "Multipart form. Exactly one of `file` or `transcription_text` is required. `medical_center_id` is required unless the call carries a Widget token.",
        "required": [
          "report_html"
        ],
        "properties": {
          "file": {
            "type": "string",
            "format": "binary",
            "description": "Audio with the dictated correction. Required unless `transcription_text` is sent."
          },
          "transcription_text": {
            "type": "string",
            "maxLength": 51200,
            "description": "The correction as text instead of audio. Max 50KB; audio wins if both are sent."
          },
          "medical_center_id": {
            "type": "string",
            "description": "Center identifier provisioned by Omnimed AI; with a Partner Credential it must be a center the credential is enabled for. Required unless a Widget token is sent, in which case it is ignored."
          },
          "report_html": {
            "type": "string",
            "description": "The last generated report, exactly as returned by the API or as last refined."
          },
          "template_html": {
            "type": "string",
            "maxLength": 102400,
            "description": "The report's original template, used as a read-only style and format reference. Max 100KB."
          },
          "template_name": {
            "type": "string",
            "description": "Template or study-type name. Enables per-study-type refinement rules."
          },
          "selected_html": {
            "type": "string",
            "description": "The exact HTML element to refine. Omit to let the model locate the target from the dictation."
          },
          "study_id": {
            "type": "integer",
            "description": "`data.metadata.study_id` from the generation call, so the refinement is tracked against it."
          },
          "user_id": {
            "type": "string",
            "description": "Doctor or user identifier, recorded for analytics."
          },
          "language": {
            "type": "string",
            "default": "spa",
            "description": "Accepted for compatibility and ignored: dictation and report use the language of the center (`medical_center_id`)."
          }
        }
      },
      "AddendumRequest": {
        "type": "object",
        "description": "Multipart form. Exactly one of `file` or `transcription_text` is required. `medical_center_id` is required unless the call carries a Widget token.",
        "properties": {
          "file": {
            "type": "string",
            "format": "binary",
            "description": "Audio with the dictated addendum. Required unless `transcription_text` is sent; if both are sent the audio wins."
          },
          "transcription_text": {
            "type": "string",
            "maxLength": 51200,
            "description": "The addendum as text instead of audio. Max 50KB; audio wins if both are sent."
          },
          "medical_center_id": {
            "type": "string",
            "description": "Center identifier provisioned by Omnimed AI; with a Partner Credential it must be a center the credential is enabled for. Required unless a Widget token is sent, in which case it is ignored."
          },
          "username": {
            "type": "string",
            "description": "Declared user: the doctor's username in the center, vouched for by the Partner. Tracked on the addendum. Ignored with a Widget token, which carries its own."
          },
          "signed_report": {
            "type": "string",
            "maxLength": 102400,
            "description": "The signed report as the host holds it, HTML or plain text, manual edits included. Max 100KB. Optional and read-only: only used to name what the doctor refers to in the report's own terms; it is never repeated or summarised, and no measurement or finding is copied from it."
          },
          "external_study_id": {
            "type": "string",
            "description": "The host's own study identifier (e.g. accession number), stored as sent for support correlation and echoed back. Never matched against Omnimed AI studies."
          },
          "user_id": {
            "type": "string",
            "description": "Doctor or user identifier, recorded for analytics."
          },
          "language": {
            "type": "string",
            "default": "spa",
            "description": "Accepted for compatibility and ignored: the addendum uses the report language of the center."
          }
        }
      },
      "WidgetTokenRequest": {
        "type": "object",
        "description": "JSON body. A token is per doctor of one center.",
        "required": [
          "medical_center_id",
          "username"
        ],
        "properties": {
          "medical_center_id": {
            "type": "string",
            "minLength": 1,
            "description": "The center the Widget acts for (the same ID as in v1). Must be enabled for the Partner Credential."
          },
          "username": {
            "type": "string",
            "minLength": 1,
            "description": "Declared user: the doctor using the Widget, by their username in the center. Decide it from your own session, never from something the browser sends."
          },
          "user_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "Your own doctor ID; only recorded."
          }
        }
      },
      "Report": {
        "type": "object",
        "required": [
          "study_name",
          "template_used",
          "template_match_confidence",
          "report_html"
        ],
        "properties": {
          "study_name": {
            "type": "string",
            "description": "Detected (or preselected) study, e.g. \"IRM de Rodilla\"."
          },
          "template_used": {
            "type": "string",
            "description": "Name of the template the report was generated against."
          },
          "template_match_confidence": {
            "type": "number",
            "format": "float",
            "minimum": 0,
            "maximum": 1,
            "description": "Confidence of the template match. Always 1.0 when `template_html` was supplied."
          },
          "report_html": {
            "type": "string",
            "description": "The generated report as HTML."
          },
          "report_rtf": {
            "type": [
              "string",
              "null"
            ],
            "description": "RTF rendering, present only when `include_rtf` was true."
          },
          "transcription_segment": {
            "type": [
              "string",
              "null"
            ],
            "description": "The portion of the transcription this report was generated from."
          },
          "template_html": {
            "type": [
              "string",
              "null"
            ],
            "description": "The sanitised template the server used, present only when `include_template` was true. Diff against this, not against your original."
          },
          "studies": {
            "type": [
              "array",
              "null"
            ],
            "description": "Only in a multi-study dictation: where each Study starts in `report_html`, in order. Null with a single study.",
            "items": {
              "type": "object",
              "required": [
                "study_name",
                "template_used",
                "template_match_confidence",
                "report_html"
              ],
              "properties": {
                "study_name": {
                  "type": "string",
                  "description": "Name of the Study, as detected in the dictation."
                },
                "template_used": {
                  "type": "string",
                  "description": "Name of the template this Study was generated from."
                },
                "template_match_confidence": {
                  "type": "number",
                  "format": "float",
                  "description": "Confidence of this Study's template match."
                },
                "report_html": {
                  "type": "string",
                  "description": "This Study's part, exactly as it appears in the combined `report_html` (the parts joined with <br><br>)."
                }
              }
            }
          }
        }
      },
      "GenerateResponse": {
        "type": "object",
        "required": [
          "success",
          "message",
          "data"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "message": {
            "type": "string",
            "description": "Human-readable summary, e.g. \"Successfully processed audio and generated 1 report(s)\"."
          },
          "data": {
            "type": "object",
            "required": [
              "transcription",
              "reports"
            ],
            "properties": {
              "transcription": {
                "type": "string",
                "description": "Full transcription of the dictation."
              },
              "language_detected": {
                "type": "string",
                "description": "Language detected in the audio (dictation is transcribed in the center's language). Echoes the `language` parameter when the input was text."
              },
              "duration_seconds": {
                "type": "number",
                "format": "float",
                "description": "Length of the audio. 0.0 when the input was text."
              },
              "speaker_count": {
                "type": "integer",
                "description": "Speakers detected in the audio. 1 when the input was text."
              },
              "reports": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/Report"
                },
                "description": "Always exactly one entry. Several dictated studies are concatenated into it (`study_name` / `template_used` comma-separated, `metadata.is_multi_study` true)."
              },
              "warnings": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/ContextMismatchWarning"
                },
                "description": "Discrepancies between `dicom_metadata` and the dictation. Informational: the report is generated the same."
              },
              "context_checks": {
                "type": "object",
                "description": "Outcome of each mismatch check, keyed by laterality, modality, region.",
                "additionalProperties": {
                  "$ref": "#/components/schemas/ContextCheckStatus"
                }
              },
              "processing_time_ms": {
                "type": "integer",
                "description": "Total server-side processing time for the request."
              },
              "metadata": {
                "type": "object",
                "additionalProperties": true,
                "properties": {
                  "study_id": {
                    "type": "integer",
                    "description": "Keep this if you plan to refine the report later."
                  },
                  "detection_source": {
                    "type": "string",
                    "enum": [
                      "auto_detect",
                      "preselected_template"
                    ],
                    "description": "How the study was determined."
                  },
                  "input_source": {
                    "type": "string",
                    "enum": [
                      "audio",
                      "text"
                    ],
                    "description": "Whether the pipeline started from audio or from `transcription_text`."
                  },
                  "is_multi_study": {
                    "type": "boolean",
                    "description": "True when the dictation covered several studies and their reports were concatenated."
                  }
                },
                "description": "Request-scoped details. Additional keys may be added over time, so parse tolerantly."
              },
              "dicom_metadata_used": {
                "type": [
                  "object",
                  "null"
                ],
                "description": "The `dicom_metadata` that was used, as parsed, or null when none was sent."
              }
            },
            "description": "The transcription and the report(s) generated from it."
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "description": "Server time of the response, ISO 8601. Quote it when contacting support."
          }
        }
      },
      "RefineResponse": {
        "type": "object",
        "required": [
          "success",
          "message",
          "data"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "message": {
            "type": "string",
            "description": "Human-readable summary, e.g. \"Successfully refined report\"."
          },
          "data": {
            "type": "object",
            "required": [
              "transcription",
              "report_html"
            ],
            "properties": {
              "transcription": {
                "type": "string",
                "description": "The dictated correction as transcribed."
              },
              "report_html": {
                "type": "string",
                "description": "The full report with the correction already applied."
              },
              "patch": {
                "type": "object",
                "description": "The exact regions that changed, for splicing into your own editor: replace the first occurrence of `target_html` with `refined_text`, and of `conclusion_original_text` with `conclusion_text` when non-null.",
                "properties": {
                  "refined_text": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The replacement HTML for `target_html`."
                  },
                  "target_html": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The HTML element in the original report that was replaced."
                  },
                  "conclusion_text": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The rewritten Conclusion, or null when the correction did not affect it."
                  },
                  "conclusion_original_text": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The Conclusion as it was before the correction, or null when unchanged."
                  }
                }
              },
              "processing_time_ms": {
                "type": "integer",
                "description": "Total server-side processing time for the request."
              },
              "metadata": {
                "type": "object",
                "additionalProperties": true,
                "properties": {
                  "medical_center_id": {
                    "type": "string",
                    "description": "The center the refinement was attributed to."
                  },
                  "study_id": {
                    "type": [
                      "integer",
                      "null"
                    ],
                    "description": "The study this refinement was tracked against, when `study_id` was sent."
                  },
                  "event_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Identifier of the refinement event, for support requests."
                  },
                  "model_used": {
                    "type": "string",
                    "description": "Identifier of the model that produced the correction. Informational; it can change without notice."
                  }
                },
                "description": "Request-scoped details. Additional keys may be added over time, so parse tolerantly."
              }
            },
            "description": "The transcribed correction and the updated report."
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "description": "Server time of the response, ISO 8601. Quote it when contacting support."
          }
        }
      },
      "AddendumResponse": {
        "type": "object",
        "required": [
          "success",
          "message",
          "data"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": true
          },
          "message": {
            "type": "string",
            "description": "Human-readable summary, e.g. \"Successfully generated addendum\"."
          },
          "data": {
            "type": "object",
            "description": "The addendum and the transcribed dictation.",
            "required": [
              "addendum_html",
              "transcription"
            ],
            "properties": {
              "addendum_html": {
                "type": "string",
                "description": "Only what was dictated, as plain paragraphs with no title, in the center's report language. Append it under your own heading, date and signature."
              },
              "transcription": {
                "type": "string",
                "description": "The dictation as transcribed, or the `transcription_text` sent."
              },
              "processing_time_ms": {
                "type": "number",
                "description": "Total server-side processing time for the request."
              },
              "metadata": {
                "type": "object",
                "additionalProperties": true,
                "description": "Request-scoped details. Additional keys may be added over time, so parse tolerantly.",
                "properties": {
                  "medical_center_id": {
                    "type": "string",
                    "description": "The center the addendum was attributed to."
                  },
                  "event_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Identifier of the addendum event, for support requests."
                  },
                  "audio_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Identifier of the retained audio, when the center retains audio."
                  },
                  "external_study_id": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "The `external_study_id` sent, echoed back."
                  },
                  "input_source": {
                    "type": "string",
                    "enum": [
                      "audio",
                      "text"
                    ],
                    "description": "Whether the addendum came from audio or from `transcription_text`."
                  },
                  "model_used": {
                    "type": [
                      "string",
                      "null"
                    ],
                    "description": "Identifier of the model that wrote the addendum. Informational; it can change without notice."
                  }
                }
              }
            }
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "description": "Server time of the response, ISO 8601. Quote it when contacting support."
          }
        }
      },
      "WidgetTokenResponse": {
        "type": "object",
        "required": [
          "token",
          "expires_at",
          "expires_in"
        ],
        "properties": {
          "token": {
            "type": "string",
            "description": "The Widget token (`omw_...`), to hand to the page and pass to `OmniWidget.init` / return from `getToken()`."
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "description": "Expiry, ISO 8601 in UTC."
          },
          "expires_in": {
            "type": "integer",
            "description": "Seconds until the token expires (about 3600)."
          }
        }
      },
      "HealthResponse": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "healthy",
              "degraded",
              "unhealthy"
            ],
            "description": "Overall service status."
          },
          "services": {
            "type": "object",
            "additionalProperties": {
              "type": "object",
              "properties": {
                "service": {
                  "type": "string",
                  "description": "Service name, repeated inside the entry."
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "healthy",
                    "degraded",
                    "unhealthy"
                  ],
                  "description": "Status of this individual service."
                },
                "available": {
                  "type": "boolean",
                  "description": "Whether this service is currently reachable."
                }
              }
            },
            "description": "Per-service status, keyed by service name."
          }
        }
      },
      "ErrorResponse": {
        "type": "object",
        "description": "General errors (input, center, processing). Credential and token errors use `CredentialErrorResponse`; request validation errors use `ValidationErrorResponse`.",
        "required": [
          "success",
          "error",
          "error_code"
        ],
        "properties": {
          "success": {
            "type": "boolean",
            "const": false,
            "description": "Always false on an error response."
          },
          "error": {
            "type": "string",
            "description": "Human-readable error message."
          },
          "error_code": {
            "type": "integer",
            "description": "Usually mirrors the HTTP status. `1011`: the center has no templates (status 422).",
            "examples": [
              400,
              403,
              500,
              1011
            ]
          },
          "timestamp": {
            "type": "string",
            "format": "date-time",
            "description": "Server time of the response, ISO 8601. Quote it when contacting support."
          }
        }
      },
      "CredentialErrorResponse": {
        "type": "object",
        "description": "Credential and token errors. Unlike `ErrorResponse`, the body is `{ \"detail\": { \"code\", \"message\" } }`; branch on `detail.code`.",
        "required": [
          "detail"
        ],
        "properties": {
          "detail": {
            "type": "object",
            "description": "Why the credential or token was not accepted.",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "missing_partner_credential",
                  "invalid_partner_credential",
                  "partner_credential_revoked",
                  "widget_token_expired",
                  "invalid_widget_token",
                  "center_not_enabled",
                  "widget_token_not_configured",
                  "partner_lookup_unavailable"
                ],
                "description": "Stable machine-readable reason. 401: `missing_partner_credential` (token exchange without a Bearer), `invalid_partner_credential`, `partner_credential_revoked`, `widget_token_expired`, `invalid_widget_token`. 403: `center_not_enabled`. 503 (retry): `widget_token_not_configured`, `partner_lookup_unavailable`. New codes may be added."
              },
              "message": {
                "type": "string",
                "description": "Human-readable explanation, in Spanish. May change; do not parse it."
              }
            }
          }
        }
      },
      "ValidationErrorResponse": {
        "type": "object",
        "description": "FastAPI request validation error: a required field is missing or has the wrong type.",
        "required": [
          "detail"
        ],
        "properties": {
          "detail": {
            "type": "array",
            "description": "One entry per invalid field.",
            "items": {
              "type": "object",
              "properties": {
                "type": {
                  "type": "string",
                  "description": "Error type, e.g. \"missing\"."
                },
                "loc": {
                  "type": "array",
                  "items": {
                    "type": [
                      "string",
                      "integer"
                    ]
                  },
                  "description": "Location of the field, e.g. [\"body\", \"medical_center_id\"]."
                },
                "msg": {
                  "type": "string",
                  "description": "Human-readable message."
                }
              }
            }
          }
        }
      },
      "DicomMetadata": {
        "type": "object",
        "description": "Ordered study sent as `dicom_metadata`. Drives the Context Mismatch checks.",
        "required": [
          "dicom_modality"
        ],
        "properties": {
          "dicom_modality": {
            "type": "string",
            "description": "DICOM Modality: CT, MR, DX/CR, US, MG."
          },
          "dicom_bodypart": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "DICOM Body Part Examined, one value (HAND, KNEE, SHOULDER, FOOT, BREAST, ...). Region is checked for extremities and breast only."
          },
          "dicom_laterality": {
            "type": "string",
            "description": "R, L or B (derecha / izquierda / bilateral also accepted). If absent, read from `dicom_series_description` when it names the side."
          },
          "dicom_series_description": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Series descriptions, e.g. \"TC MANO DERECHA\"."
          }
        },
        "additionalProperties": true,
        "examples": [
          {
            "dicom_modality": "CT",
            "dicom_bodypart": [
              "HAND"
            ],
            "dicom_laterality": "R",
            "dicom_series_description": [
              "TC MANO DERECHA"
            ]
          }
        ]
      },
      "ContextMismatchWarning": {
        "type": "object",
        "required": [
          "code",
          "context_value",
          "dictated_value",
          "message"
        ],
        "properties": {
          "code": {
            "type": "string",
            "enum": [
              "LATERALITY_MISMATCH",
              "MODALITY_MISMATCH",
              "REGION_MISMATCH"
            ],
            "description": "Which check found the discrepancy."
          },
          "study_name": {
            "type": [
              "string",
              "null"
            ],
            "description": "Study the warning refers to."
          },
          "context_value": {
            "type": "string",
            "description": "Value from `dicom_metadata`: laterality R/L; modality RX, TC, RM, ECO, MAMO; region a Spanish key (mano, pie, rodilla, muneca, ...)."
          },
          "dictated_value": {
            "type": "string",
            "description": "Value the doctor dictated, same vocabulary as `context_value`."
          },
          "evidence": {
            "type": [
              "string",
              "null"
            ],
            "description": "Literal fragment of the dictation, when available."
          },
          "message": {
            "type": "string",
            "description": "Spanish message, ready to show the radiologist."
          }
        }
      },
      "ContextCheckStatus": {
        "type": "object",
        "required": [
          "status"
        ],
        "properties": {
          "status": {
            "type": "string",
            "enum": [
              "ok",
              "mismatch",
              "skipped"
            ],
            "description": "ok: checked and consistent; mismatch: a warning was emitted; skipped: not checked (see reason)."
          },
          "reason": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "no_context",
              "unmapped",
              "not_dictated",
              "bilateral_context",
              "llm_unavailable",
              null
            ],
            "description": "Why a check was skipped. Skipped means not verified, not verified OK."
          }
        }
      }
    }
  }
}
