# Guía de integración de Omnimed AI (v2)

## Inicio rápido

URL base: `https://api.omniai.com.ar`

1. **Pedí tu Partner Credential (credencial de partner)** a omni@omniai.com.ar. Es un secreto `omp_...` que se muestra una sola vez: guardalo como variable de entorno de tu servidor. Ver [Alta de un partner](#alta-de-un-partner).
2. **Hacé la primera llamada.** Desde tu servidor, con un audio de prueba:

   ```bash
   curl -X POST https://api.omniai.com.ar/api/omni-ai \
     -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
     -F "file=@dictado.wav" \
     -F "medical_center_id=YOUR_CENTER_ID" \
     -F "username=dr.perez"
   ```

   Si vas a usar el Widget en el navegador, tu backend cambia la credencial por un token por médico y la página lo recibe con `OmniWidget.init({ getToken })` (ver [Widget v2](#widget-v2)).
3. **Leé la respuesta.** El informe viene en `data.reports[0].report_html`; guardá `data.metadata.study_id` si vas a refinarlo después (ver [Respuesta exitosa](#respuesta-exitosa)).

## Autenticación

Cada partner de integración recibe de Omnimed AI una **Partner Credential** (`omp_...`), habilitada para uno o más centros. Cada centro tiene su `medical_center_id`, que identifica sus plantillas, su idioma y su estilo de informe.

### Partner Credential (servidor a servidor)

Cuando tu servidor llama a la API directamente, manda la credencial como Bearer en cada llamada y nombra el centro y el médico en el formulario:

- `Authorization: Bearer omp_...`: tu Partner Credential. Solo en tu servidor (variable de entorno, gestor de secretos); nunca en el navegador.
- `medical_center_id`: un centro para el que tu credencial está habilitada.
- `username`: el **médico declarado** (ver abajo).

`/api/omni-ai/refine` y `/api/omni-ai/addendum` llevan el mismo header y el mismo `medical_center_id`.

Un header `Authorization: Bearer` cuyo valor no empieza con `omp_` ni con `omw_` se ignora: la llamada se trata como si no trajera credencial.

### Token del Widget

El Widget corre en el navegador del médico, así que nunca recibe la Partner Credential. Tu backend la cambia por un **token del Widget** (`omw_...`) con `POST /api/widget/token`, para un centro y un médico, y se lo pasa a la página. El token dura una hora y sirve para un solo médico de un solo centro. El Widget lo manda como `Authorization: Bearer omw_...` en cada llamada y, cuando vence, le pide uno nuevo a tu página con `getToken()`.

![Flujo del token del Widget: el Widget llama a getToken() en tu backend; tu backend pide el token a POST /api/widget/token con su Partner Credential; la API devuelve un token omw_ de una hora; el Widget lo usa como Bearer en el dictado, el refinamiento y la adenda, y pide otro cuando vence o recibe un 401](https://www.omniai.com.ar/docs/widget-token-flow.es.png)

Con un token del Widget, el centro y el médico salen del token: si la llamada además manda `medical_center_id` o `username`, se ignoran.

Un token no se revoca por separado: vence a la hora. Lo que se revoca o se rota es la Partner Credential, y los tokens que emitió dejan de valer en menos de 60 segundos.

### Médico declarado (`username`)

`username` es el usuario del médico en el centro (el mismo login que usa en la app de escritorio). Lo declara tu sistema con su credencial: nosotros no autenticamos a ese médico, confiamos en tu palabra dentro de los centros para los que estás habilitado. Decide entre qué plantillas se elige la del dictado:

| `username` | Plantillas entre las que se elige |
|------------|-----------------------------------|
| ausente (solo API directa) | Solo las plantillas generales del centro |
| un usuario del centro | Las generales más las asignadas a ese médico (directamente o por grupo) |
| un usuario que el centro no conoce | Solo las generales; la llamada queda registrada con el nombre declarado |

Nunca se crea un usuario a partir de un `username`. Decidilo en tu backend a partir de tu propia sesión, nunca a partir de algo que mande el navegador: así un médico no puede actuar a nombre de otro.

## Widget v2

### 1. Tu backend pide el token

```
POST https://api.omniai.com.ar/api/widget/token
Authorization: Bearer omp_...
Content-Type: application/json
```

| Dónde | Campo | Requerido | Descripción |
|-------|-------|-----------|-------------|
| Header | `Authorization: Bearer omp_...` | Sí | Tu Partner Credential. Solo en tu servidor |
| Body JSON | medical_center_id | Sí | El centro para el que actúa el Widget (el mismo ID que en v1). Tiene que estar habilitado para tu credencial |
| Body JSON | username | Sí | El médico que usa el Widget, con su usuario en el centro (médico declarado). Un token es por médico |
| Body JSON | user_id | No | Tu propio ID del médico; solo se registra |

Respuesta `200`:

```json
{
  "token": "omw_eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expires_at": "2026-10-14T18:32:10Z",
  "expires_in": 3600
}
```

- `token`: el token del Widget, para pasarle a la página.
- `expires_at`: vencimiento en ISO 8601 (UTC). Podés cachear el token por médico hasta ese momento en lugar de pedir uno en cada carga de página.
- `expires_in`: segundos hasta el vencimiento.

**cURL**

```bash
curl -X POST https://api.omniai.com.ar/api/widget/token \
  -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{"medical_center_id": "YOUR_CENTER_ID", "username": "dr.perez"}'
```

**Node (Express)**

Un endpoint de tu backend que devuelve el token como texto plano para el médico con sesión iniciada. Usa `fetch` nativo (Node 18+).

```javascript
// OMNI_PARTNER_CREDENTIAL=omp_...  (variable de entorno, nunca en el front)
app.get('/omni-token', requireLogin, async (req, res) => {
  const r = await fetch('https://api.omniai.com.ar/api/widget/token', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.OMNI_PARTNER_CREDENTIAL}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      medical_center_id: req.user.omniCenterId, // p. ej. 'YOUR_CENTER_ID'
      username: req.user.username,              // el médico de esta sesión
      user_id: String(req.user.id),             // opcional
    }),
  });
  if (!r.ok) {
    // 401: credencial inválida o revocada; 403: centro no habilitado (ver Errores)
    return res.status(502).send(await r.text());
  }
  const { token } = await r.json();
  res.type('text/plain').send(token);
});
```

El Widget llama a `getToken()` recién antes de su primera subida, no al cargar la página: un endpoint de token mal configurado recién se nota cuando el médico termina de dictar. Probalo desde tu backend (por ejemplo con el cURL de arriba) antes de salir a producción.

### 2. Inicializar el Widget en la página

```html
<script src="https://api.omniai.com.ar/static/widget/v2/omni-widget.min.js"></script>
<script>
  OmniWidget.init({
    // Lo llama el Widget antes de la primera subida y cada vez que el token vence.
    getToken: () =>
      fetch('/omni-token', { credentials: 'include' }).then((r) => {
        if (!r.ok) throw new Error('No se pudo obtener el token de Omnimed AI');
        return r.text();
      }),
    onReport: ({ html, text, recordingId }) => {
      // Insertar el informe en tu editor (ver "Autoinsertar el informe")
    },
  });
</script>
```

Se inyecta un botón de grabación flotante abajo a la derecha. El médico lo toca, dicta, frena, y el Widget muestra el informe generado.

> **Sin `data-center-id` en el `<script>`.** Si el tag conserva `data-center-id` (de una integración v1), el Widget arranca solo en modo v1 al cargar, y un `OmniWidget.init({ getToken })` posterior se ignora con el aviso "Already initialized" en la consola. En v2 el centro y el médico salen del token; si pasás `centerId` o `username` en el `init`, se ignoran.

- Si ya tenés un token al renderizar la página, podés pasarlo también: `OmniWidget.init({ token: 'omw_...', getToken })`. El Widget usa ese primero y llama a `getToken()` recién cuando vence.
- Con `token` solo, sin `getToken`, el Widget funciona una hora y después muestra un error. Pasá siempre `getToken`.
- Para un médico distinto (cambio de usuario en tu producto) llamá `OmniWidget.destroy()` y volvé a `init` con un `getToken` que traiga el token del nuevo médico.

| Opción de `init` | Descripción |
|-------------------|-------------|
| getToken | Función que devuelve (o promete) el token `omw_...` como texto. Recomendada siempre |
| token | Token inicial, opcional |
| apiUrl | URL base de la API. Por defecto, el origen desde el que se cargó el script (`https://api.omniai.com.ar`) |
| onReport | Callback con el informe, igual que el evento `omniai:report` |
| onAudio | Callback con la grabación cruda, igual que el evento `omniai:audio` |
| onAddendum | Callback con la adenda, igual que el evento `omniai:addendum` |
| addendumButton | `false` oculta el botón **Adenda** del Widget (por defecto se muestra) |

### Autoinsertar el informe

El Widget emite `omniai:report` (y llama a `onReport`) cuando el informe está listo, cada vez que el médico toca **Insertar** y después de cada refinamiento. `event.detail` trae `html`, `text` y el `recordingId` del dictado. Como el mismo dictado puede llegar varias veces, tu código tiene que reemplazar lo que ya insertó para ese `recordingId` en lugar de agregar otra copia:

```javascript
// Un contenedor por dictado: el evento siguiente del mismo recordingId lo reemplaza.
window.addEventListener('omniai:report', (event) => {
  const { html, recordingId } = event.detail;
  const editor = document.querySelector('#informe');
  let bloque = editor.querySelector(`[data-omni-recording="${recordingId}"]`);
  if (!bloque) {
    bloque = document.createElement('div');
    bloque.dataset.omniRecording = recordingId;
    editor.appendChild(bloque);
  }
  bloque.innerHTML = html; // HTML del informe, sanitizado del lado del servidor
});
```

### Guardar una copia del audio

Si tu sistema tiene que conservar el dictado original (p. ej. retención legal en el PACS), el Widget te entrega la grabación cruda en el momento en que el médico frena, **antes** de subirla, así tu copia existe aunque el procesamiento falle. Escuchá `omniai:audio` o pasá `onAudio` a `init`:

```javascript
window.addEventListener('omniai:audio', (event) => {
  const { blob, mimeType, kind, recordingId, parentRecordingId } = event.detail;
  // p. ej. subir el blob a tu storage, con tu ID de estudio + recordingId
});
```

| Campo | Descripción |
|-------|-------------|
| blob | La grabación (`Blob`), normalmente WebM/Opus (Ogg o MP4 en navegadores sin WebM) |
| mimeType | Tipo MIME del blob |
| durationSeconds | Duración de la grabación |
| sizeBytes | Tamaño del blob |
| kind | `"dictation"`, `"refinement"` (una corrección por voz) o `"addendum"` |
| recordingId | ID único de esta grabación. Los payloads de `omniai:report` y `omniai:addendum` traen el mismo `recordingId`, que vincula audio y resultado |
| parentRecordingId | En un refinamiento, el `recordingId` del dictado que corrige; `null` en los demás |
| recordedAt | Timestamp ISO 8601 |

El blob no sale de tu página por este evento: guardarlo depende de tu código.

### Adenda sobre un informe ya firmado

Cuando el médico ya firmó el informe en tu sistema y necesita agregarle algo (un hallazgo que faltó, o la comparación con estudios previos que trajo el paciente), abre una **adenda** en vez de dictar un informe nuevo. El Widget graba y la API devuelve **solo lo dictado**, en párrafos, sin título y en el idioma de informes del centro; vos lo agregás al informe con tu propio encabezado, fecha y firma. El informe firmado nunca se modifica. No se elige plantilla, no hay "Refinar" y las adendas no consumen créditos.

```html
<button id="agregar-adenda">Agregar adenda</button>
<script>
  // 1. Tu botón abre la adenda y el Widget empieza a grabar enseguida.
  document.getElementById('agregar-adenda').addEventListener('click', () => {
    OmniWidget.openAddendum({
      signedReport: miVisor.informeFirmadoHtml(), // opcional: HTML o texto, tal como lo tenés
      externalStudyId: miVisor.accessionNumber(), // opcional: tu ID del estudio
    });
  });

  // 2. Cuando el médico toca "Insertar", llega la adenda para que la agregues.
  window.addEventListener('omniai:addendum', (e) => {
    const { html, text, externalStudyId, transcription } = e.detail;
    miVisor.agregarAdenda(externalStudyId, {
      titulo: 'ADENDA',                      // el encabezado es tuyo
      fecha: new Date(),
      firma: miVisor.medicoActual(),
      cuerpo: html,                          // o `text` si tu campo es texto plano
    });
  });
</script>
```

- `openAddendum()` devuelve una promesa con `true` si empezó a grabar, o `false` si ya había un dictado en curso (el médico tiene que terminarlo o cancelarlo antes).
- `signedReport` es opcional. Si tu sistema no puede mandarlo, la adenda funciona igual (ver [qué se hace con el informe firmado](#agregar-una-adenda-a-un-informe-firmado)).
- `externalStudyId` se guarda tal cual en nuestro registro, para que soporte pueda ubicar la adenda, y vuelve en el evento para que sepas a qué estudio pertenece. No se cruza con nuestros estudios.
- El resultado se muestra en el Widget en modo lectura, con **Copiar**, **Insertar** y **Volver a dictar**. Para corregirlo, el médico vuelve a dictar y el nuevo resultado reemplaza al anterior.
- `omniai:addendum` (y `onAddendum` en el `init`) se emite **solo al tocar Insertar**, una vez por toque: cada evento es una adenda para agregar.
- El Widget también muestra un botón **Adenda** junto a "Omnimed AI", que abre una adenda sin informe firmado ni ID de estudio. Si preferís usar solo tu botón, ocultalo con `OmniWidget.init({ ..., addendumButton: false })`.

| Campo de `omniai:addendum` | Descripción |
|----------------------------|-------------|
| html | La adenda en HTML (párrafos, sin título) |
| text | La misma adenda en texto plano |
| transcription | El dictado transcripto |
| externalStudyId | El que pasaste a `openAddendum`, o `null` |
| recordingId | El mismo del `omniai:audio` de esa grabación (que llega con `kind: "addendum"`) |
| eventId | ID del evento en nuestro registro, para soporte |

### Renovación del token

El Widget renueva solo:

1. Antes de cada llamada, si el token vence en menos de 30 segundos, pide uno nuevo con `getToken()`.
2. Si igual la API responde `401`, llama a `getToken()` una vez y repite la misma llamada con el mismo audio.
3. Si el segundo intento también da `401`, muestra el error en el panel con un botón **Reintentar**. El dictado no se pierde: queda en el Widget hasta que el médico reintente, grabe otro o cierre el panel (y además ya se entregó a tu página por `omniai:audio` antes de subirlo).

Si `getToken()` lanza un error o no devuelve un texto, el Widget muestra "No se pudo obtener el token del Widget: ..." con el mismo botón **Reintentar**.

### Ciclo de vida del Widget

| Estado | Significado |
|--------|-------------|
| idle | Listo para grabar |
| recording | Micrófono activo |
| paused | Grabación en pausa |
| processing | Audio subido, generando informe |
| result | Informe visible en el panel |
| error | Falló el procesamiento o el token; el médico puede reintentar o volver a grabar |

### Requisitos del navegador y de red

- HTTPS (requerido para acceder al micrófono).
- Permiso otorgado para `navigator.mediaDevices.getUserMedia`.
- Duración máxima de una grabación: 10 minutos.
- Si tu página usa Content Security Policy, permití `https://api.omniai.com.ar` en `script-src` (el script del Widget) y en `connect-src` (sus llamadas a la API).
- El archivo `/static/widget/v2/omni-widget.min.js` recibe correcciones compatibles sin cambiar de URL, así que por ahora no publicamos hashes de Subresource Integrity (SRI) para fijarlo.

---

## API directa

Todas las llamadas de esta sección se hacen desde tu servidor con `Authorization: Bearer omp_...` (o desde el Widget con su token `omw_...`).

El Widget usa esta API para generar el informe (`POST /api/omni-ai`) y la adenda (`POST /api/omni-ai/addendum`). Para refinar no usa `/api/omni-ai/refine`: transcribe la corrección con `/api/transcription/transcribe-upload` y la aplica con `/api/reports/refine`, con el mismo token. Desde tu servidor, refiná con `/api/omni-ai/refine`.

### Generar un informe

```
POST https://api.omniai.com.ar/api/omni-ai
Authorization: Bearer omp_...
Content-Type: multipart/form-data
```

#### Parámetros

| Parámetro | Tipo | Requerido | Descripción |
|-----------|------|-----------|-------------|
| file | File | Condicional | Archivo de audio (wav, mp3, m4a, ogg, flac, webm), hasta 100 MB. Requerido salvo que se envíe `transcription_text` |
| transcription_text | String | Condicional | Texto del dictado usado en lugar del audio (ver abajo). Requerido salvo que se envíe `file` |
| medical_center_id | String | Sí | Centro habilitado para tu credencial. Con un token del Widget no hace falta y se ignora |
| username | String | Recomendado | Médico declarado: el usuario del médico en el centro. La plantilla se elige entre las visibles para ese médico (generales + asignadas). Ausente o desconocido: solo generales. Se ignora si se envía `template_html` |
| user_id | String | No | Identificador del médico/usuario para analítica |
| language | String | No | No cambia el idioma: el dictado y el informe usan el idioma del centro. Solo se devuelve como `language_detected` cuando la entrada es `transcription_text` |
| metadata | String (JSON) | No | Metadata JSON libre, máx 10KB |
| dicom_metadata | String (JSON) | No | Estudio pedido, de la orden / headers DICOM; dispara los avisos de discrepancia (ver abajo) |
| template_html | String (HTML) | No | Plantilla preseleccionada: generar el informe contra exactamente esta plantilla (ver abajo) |
| template_name | String | No | Etiqueta de la plantilla preseleccionada; se devuelve como `study_name` / `template_used` |
| include_template | Boolean | No | Devolver el HTML de la plantilla que usó el servidor, para resaltar diferencias |
| include_rtf | Boolean | No | Devolver versión RTF de cada informe |

> **Nota**: Sin `template_html`, el estudio se detecta automáticamente a partir de la transcripción y la plantilla se elige entre las plantillas guardadas del centro: no necesitás enviar el nombre del estudio.

> **Varios estudios en un dictado**: `reports` siempre tiene una sola entrada. Si el médico dicta varios estudios, sus informes vienen concatenados en ese único `report_html`, `study_name` / `template_used` los listan separados por coma, y `metadata.is_multi_study` es `true`.

#### Enviar texto en lugar de audio (`transcription_text`)

Si tu sistema ya tiene el dictado como texto (tipeado, o transcripto de tu lado), envialo como `transcription_text` y omití el audio. Se omite el paso de transcripción y el texto entra al pipeline exactamente donde entraría la transcripción: la detección del estudio, la elección de plantilla y la generación se comportan igual. Se combina naturalmente con `template_html` (sin transcripción *y* sin elección de plantilla: el camino más rápido y totalmente determinístico).

- **El audio siempre gana.** Si enviás ambos, se transcribe el audio y `transcription_text` se ignora por completo. No hay fallback: si el audio falla al transcribirse (p. ej. micrófono muteado) la llamada da error aunque hayas enviado texto.
- Un campo `file` en blanco o un `transcription_text` en blanco ("" o espacios) se trata como ausente. No enviar ninguno devuelve un 400.
- Texto plano, máx **50KB**.
- En la respuesta: `duration_seconds` es `0.0`, `speaker_count` es `1`, `language_detected` es el valor del campo `language` (`"spa"` si no lo mandás), y `metadata.input_source` es `"text"` (vs `"audio"`).
- La facturación no cambia: un informe generado desde texto consume crédito exactamente igual que uno generado desde audio.

#### Enviar tu propia plantilla (`template_html`)

Cuando tu sistema sabe qué plantilla debe seguir el informe (por clínica, por centro derivador, por modalidad), enviala en la llamada:

- La detección del estudio y la elección de plantilla se **omiten por completo**: el informe sigue tu plantilla de forma determinística.
- El resultado es siempre un **único informe**, aunque el dictado mencione varios estudios.
- El HTML se sanitiza del lado del servidor antes de usarse; el tamaño máximo es **100KB**. HTML demasiado grande, o markup que queda vacío tras la sanitización, devuelve un 400. Un campo en blanco ("" o espacios) se trata como ausente: la llamada sigue el camino normal de elección de plantilla.
- HTML es el formato recomendado: preserva negritas, títulos y estructura de bloques.
- En la respuesta: `template_match_confidence` es `1.0` y `metadata.detection_source` es `"preselected_template"`.
- Con `include_template=true`, el `template_html` de la respuesta es la plantilla sanitizada que el servidor usó realmente: hacé el diff contra esa, no contra tu original.

#### Respuesta exitosa

Una llamada sin `template_html`, con la plantilla elegida automáticamente:

```json
{
  "success": true,
  "message": "Successfully processed audio and generated 1 report(s)",
  "data": {
    "transcription": "...",
    "language_detected": "spa",
    "duration_seconds": 42.1,
    "speaker_count": 1,
    "reports": [
      {
        "study_name": "IRM de Rodilla",
        "template_used": "IRM de Rodilla",
        "template_match_confidence": 0.91,
        "report_html": "<p>...</p>",
        "report_rtf": null,
        "transcription_segment": "...",
        "template_html": null
      }
    ],
    "warnings": [],
    "context_checks": {
      "laterality": {"status": "skipped", "reason": "no_context"},
      "modality": {"status": "skipped", "reason": "no_context"},
      "region": {"status": "skipped", "reason": "no_context"}
    },
    "processing_time_ms": 7441,
    "metadata": {
      "study_id": 12345,
      "detection_source": "auto_detect",
      "input_source": "audio",
      "is_multi_study": false,
      "...": "..."
    },
    "dicom_metadata_used": null
  },
  "timestamp": "2026-10-14T15:32:10.123456"
}
```

- `data.reports[0].report_html`: el informe.
- `data.metadata.study_id`: guardalo si vas a refinar este informe más adelante.
- `data.dicom_metadata_used`: el `dicom_metadata` que se usó, ya interpretado, o `null` si no se mandó.
- `metadata` puede sumar claves nuevas con el tiempo: parseá la respuesta de forma tolerante.

#### Avisos de discrepancia (lateralidad, modalidad, región)

Enviá el estudio pedido en `dicom_metadata` y la API lo compara con lo que dictó el médico. Una discrepancia vuelve en `data.warnings`; el informe se genera exactamente igual y nunca se modifica ni se bloquea por eso. Ninguno de los dos lados se asume correcto: la orden también puede estar mal.

```json
{
  "dicom_modality": "CT",
  "dicom_bodypart": ["HAND"],
  "dicom_laterality": "R",
  "dicom_series_description": ["TC MANO DERECHA"]
}
```

- `dicom_modality` (requerido si se envía `dicom_metadata`): Modality DICOM (`CT`, `MR`, `DX`/`CR`, `US`, `MG`).
- `dicom_bodypart`: Body Part Examined DICOM, un valor (`HAND`, `KNEE`, `SHOULDER`, `FOOT`, `BREAST`, ...). La región se chequea en extremidades y mama; el resto todavía no.
- `dicom_laterality`: `R`, `L` o `B` (también se acepta `derecha` / `izquierda` / `bilateral`). Si falta, el lado se lee de `dicom_series_description` cuando lo dice en palabras (`DER`, `IZQ`, `DERECHA`, `LEFT`, ...).
- El contexto libre, como la indicación clínica, va en `metadata`: es contexto para el informe, no entrada de estos chequeos.

Cada aviso:

```json
{
  "code": "LATERALITY_MISMATCH",
  "study_name": "custom",
  "context_value": "R",
  "dictated_value": "L",
  "evidence": "quinto metacarpiano del pie izquierdo",
  "message": "El estudio está indicado como derecho y se dictó izquierdo."
}
```

- `code`: `LATERALITY_MISMATCH`, `MODALITY_MISMATCH` o `REGION_MISMATCH`.
- `context_value` / `dictated_value`: lateralidad `R`/`L`; modalidad `RX`, `TC`, `RM`, `ECO`, `MAMO`; región una clave en español como `mano`, `pie`, `rodilla`, `muneca`.
- `evidence`: fragmento literal del dictado, cuando lo hay. `message`: en español, listo para mostrarle al radiólogo.

`context_checks` indica, por chequeo, si corrió: `ok`, `mismatch`, o `skipped` con un `reason` (`no_context`: no se envió; `unmapped`: un valor que todavía no chequeamos; `not_dictated`: el médico no lo dijo; `bilateral_context`: la orden es bilateral; `llm_unavailable`: el chequeo no llegó a terminar a tiempo). Un `warnings` vacío con chequeos `skipped` significa "no verificado", no "verificado OK".

Los chequeos están calibrados para evitar falsas alarmas: ante la duda no avisan. Mencionar el otro lado para comparar ("comparado con la rodilla izquierda") u otra modalidad como sugerencia ("se sugiere RM") no son discrepancias.

#### Ejemplos

Todo campo que lleve HTML (`template_html`, `report_html`, `signed_report`) va con `--form-string` en cURL: con `-F`, un valor que empieza con `<` o `@` se interpreta como un archivo a leer.

**cURL**

```bash
curl -X POST https://api.omniai.com.ar/api/omni-ai \
  -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
  -F "file=@recording.m4a" \
  -F "medical_center_id=YOUR_CENTER_ID" \
  -F "username=dr.martinez"
```

**cURL (plantilla preseleccionada)**

```bash
curl -X POST https://api.omniai.com.ar/api/omni-ai \
  -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
  -F "file=@recording.m4a" \
  -F "medical_center_id=YOUR_CENTER_ID" \
  -F "username=dr.martinez" \
  --form-string "template_html=<h2>IRM de Rodilla</h2><p>...</p>" \
  -F "template_name=IRM de Rodilla" \
  -F "include_template=true"
```

**Python**

```python
import os
import requests

url = "https://api.omniai.com.ar/api/omni-ai"
headers = {"Authorization": f"Bearer {os.environ['OMNI_PARTNER_CREDENTIAL']}"}

with open("audio.m4a", "rb") as f:
    response = requests.post(
        url,
        headers=headers,
        files={"file": f},
        data={
            "medical_center_id": "YOUR_CENTER_ID",
            "username": "dr.lopez",
        },
        timeout=120,
    )

response.raise_for_status()
result = response.json()

for report in result["data"]["reports"]:
    print(report["study_name"], "->", report["report_html"])
```

**JavaScript (Node 18+, en tu servidor)**

```javascript
async function processAudio(audioBlob, centerId, username) {
  const form = new FormData();
  form.append('file', audioBlob, 'dictado.webm');
  form.append('medical_center_id', centerId);
  form.append('username', username);

  const res = await fetch('https://api.omniai.com.ar/api/omni-ai', {
    method: 'POST',
    headers: { Authorization: `Bearer ${process.env.OMNI_PARTNER_CREDENTIAL}` },
    body: form,
    signal: AbortSignal.timeout(120_000),
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    // Errores de credencial: err.detail.code; el resto: err.error
    throw new Error(err.detail?.code || err.error || `HTTP ${res.status}`);
  }

  const { data } = await res.json();
  return data.reports[0].report_html;
}
```

### Refinar un informe

```
POST https://api.omniai.com.ar/api/omni-ai/refine
Authorization: Bearer omp_...
Content-Type: multipart/form-data
```

El médico dicta una corrección sobre un informe ya generado ("cambiar menisco interno por menisco externo"). El servidor transcribe el audio, cambia **solo** el elemento apuntado (más la Conclusión del informe cuando el cambio la afecta) y devuelve el informe actualizado. El refinamiento nunca regenera el informe completo, y no se factura.

#### Parámetros

| Parámetro | Tipo | Requerido | Descripción |
|-----------|------|-----------|-------------|
| file | File | Condicional | Audio con la corrección dictada. Requerido salvo que se envíe `transcription_text` |
| transcription_text | String | Condicional | La corrección como texto en lugar de audio (máx 50KB, mismas reglas que en generación: el audio gana, blanco = ausente) |
| medical_center_id | String | Sí | Centro habilitado para tu credencial. Con un token del Widget no hace falta y se ignora |
| report_html | String (HTML) | Sí | El último informe generado, exactamente como lo devolvió la API (o como quedó tras el último refinamiento) |
| template_html | String (HTML) | No | La plantilla original del informe; se usa como referencia de estilo/formato de solo lectura (máx 100KB) |
| template_name | String | No | Nombre de la plantilla/tipo de estudio, habilita reglas por tipo de estudio |
| selected_html | String (HTML) | No | Elemento HTML exacto a refinar; omitilo para que el modelo ubique el objetivo desde el dictado |
| study_id | Integer | No | El `metadata.study_id` de la llamada de generación, para trazar el refinamiento |
| user_id | String | No | Identificador del médico/usuario para analítica |
| language | String | No | Se acepta por compatibilidad y se ignora: se usa el idioma del centro |

#### Respuesta exitosa

```json
{
  "success": true,
  "message": "Successfully refined report",
  "data": {
    "transcription": "cambiar menisco interno por menisco externo",
    "report_html": "<p>...informe completo con la corrección aplicada...</p>",
    "patch": {
      "refined_text": "<p>Menisco externo sin alteraciones.</p>",
      "target_html": "<p>Menisco interno sin alteraciones.</p>",
      "conclusion_text": null,
      "conclusion_original_text": null
    },
    "processing_time_ms": 3120,
    "metadata": {
      "medical_center_id": "your-center",
      "study_id": 12345,
      "event_id": "678",
      "...": "..."
    }
  },
  "timestamp": "2026-10-14T15:34:02.456789"
}
```

- `data.report_html` es el informe completo con la corrección ya aplicada: usalo directamente.
- `data.patch` describe las regiones exactas que cambiaron, si preferís empalmarlas en tu propio editor: reemplazá la primera ocurrencia de `target_html` por `refined_text`, y la de `conclusion_original_text` por `conclusion_text` (cuando no sea null).
- Para encadenar refinamientos, enviá el `report_html` devuelto como entrada de la siguiente llamada de refine.

#### Ejemplo

```bash
curl -X POST https://api.omniai.com.ar/api/omni-ai/refine \
  -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
  -F "file=@correction.wav" \
  -F "medical_center_id=YOUR_CENTER_ID" \
  --form-string "report_html=<p>...último informe generado...</p>" \
  --form-string "template_html=<p>...plantilla original...</p>" \
  -F "study_id=12345"
```

#### Resaltar los cambios de la IA (diff en el cliente)

El Widget resalta lo que cambió la IA haciendo un diff entre la plantilla y el informe generado. Podés hacer lo mismo en tu interfaz con [jsdiff](https://github.com/kpdecker/jsdiff) (`diff` en npm), sin soporte del servidor:

```javascript
import { diffWords } from 'diff';

function highlightChanges(templateText, reportText) {
  return diffWords(templateText, reportText)
    .map((part) =>
      part.added
        ? `<mark class="ai-change">${part.value}</mark>`
        : part.removed
          ? ''
          : part.value
    )
    .join('');
}
```

- Línea base: pedí `include_template=true` en la generación y hacé el diff contra el `template_html` devuelto.
- Después de un refine, hacé el diff entre el `report_html` anterior y el nuevo para resaltar solo la corrección.
- Hacé el diff sobre el texto visible (p. ej. vía `DOMParser`) en lugar de strings HTML crudos para resaltados más limpios.

### Agregar una adenda a un informe firmado

```
POST https://api.omniai.com.ar/api/omni-ai/addendum
Authorization: Bearer omp_...
Content-Type: multipart/form-data
```

El médico ya firmó el informe en tu sistema y dicta algo para agregarle (un hallazgo que faltó, o la comparación cuando el paciente trae estudios previos). El servidor devuelve **solo lo dictado**, en párrafos sin título y en el idioma de informes del centro. Tu sistema le pone su propio encabezado ("ADENDA"), fecha y firma y lo agrega; el informe firmado nunca se modifica. No se elige plantilla y las adendas no se facturan. Desde el Widget es el mismo flujo con `OmniWidget.openAddendum` (ver [Adenda sobre un informe ya firmado](#adenda-sobre-un-informe-ya-firmado)).

> **El informe firmado es solo referencia.** `signed_report` (`signedReport` en el Widget) se usa únicamente para nombrar lo que el médico menciona con los términos del informe ("el nódulo de arriba" sale como lo nombra el informe). Nunca se repite ni se resume, y nunca se copian medidas ni hallazgos que el médico no dictó.

#### Parámetros

| Parámetro | Tipo | Requerido | Descripción |
|-----------|------|-----------|-------------|
| file | File | Condicional | Audio con la adenda dictada. Requerido salvo que se envíe `transcription_text` |
| transcription_text | String | Condicional | La adenda como texto en lugar de audio (máx 50KB, mismas reglas que en generación: el audio gana, blanco = ausente) |
| medical_center_id | String | Sí | Centro habilitado para tu credencial. Con un token del Widget no hace falta y se ignora |
| username | String | Recomendado | Médico declarado; queda registrado en la adenda |
| signed_report | String (HTML o texto) | No | El informe firmado tal como lo tiene tu sistema, ediciones manuales incluidas (máx 100KB) |
| external_study_id | String | No | Tu propio identificador del estudio, guardado tal cual, para ubicar la adenda en soporte |
| user_id | String | No | Identificador del médico/usuario para analítica |
| language | String | No | Se acepta por compatibilidad y se ignora: se usa el idioma del centro |

#### Respuesta exitosa

```json
{
  "success": true,
  "message": "Successfully generated addendum",
  "data": {
    "addendum_html": "<p>En la región mamaria derecha se identifica una lesión nodular de 12 x 9 mm.</p>",
    "transcription": "esto es una adenda, agregar una nota de que en la región mamaria derecha se identifica lesión nodular de 12 por 9 milímetros",
    "processing_time_ms": 2410,
    "metadata": {
      "medical_center_id": "your-center",
      "event_id": "678",
      "audio_id": null,
      "external_study_id": "ACC-00042",
      "input_source": "audio",
      "...": "..."
    }
  },
  "timestamp": "2026-10-14T15:40:11.789012"
}
```

- `data.addendum_html`: la adenda, lista para agregar debajo de tu encabezado.
- `data.transcription`: el dictado transcripto (o el `transcription_text` que enviaste).
- Para corregir una adenda, el médico la vuelve a dictar: no hay refine de adendas.

#### Ejemplo

```bash
curl -X POST https://api.omniai.com.ar/api/omni-ai/addendum \
  -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
  -F "file=@addendum.wav" \
  -F "medical_center_id=YOUR_CENTER_ID" \
  -F "username=dr.perez" \
  --form-string "signed_report=<p>...informe firmado...</p>" \
  -F "external_study_id=ACC-00042"
```

## Errores

Los errores devuelven un status no-2xx. Hay tres formas de cuerpo; distinguilas por la clave de primer nivel y por el tipo de `detail`.

### Errores de credencial y token

`detail` es un objeto. Ramificá por `detail.code`, que es estable; `detail.message` es texto para humanos y puede cambiar:

```json
{
  "detail": {
    "code": "center_not_enabled",
    "message": "La Partner Credential no está habilitada para el centro 'your-center'."
  }
}
```

| Status | `detail.code` | Qué significa | Qué hacer |
|--------|---------------|---------------|-----------|
| 401 | `missing_partner_credential` | `POST /api/widget/token` sin `Authorization: Bearer omp_...` | Mandá la credencial como Bearer desde tu servidor |
| 401 | `invalid_partner_credential` | Credencial desconocida (mal copiada o rotada) | Revisá el valor; si se rotó, usá la nueva |
| 401 | `partner_credential_revoked` | Credencial revocada. Los tokens del Widget que emitió dejan de valer en menos de 60 segundos | Pedinos una credencial nueva |
| 401 | `widget_token_expired` | Token del Widget vencido | El Widget lo renueva solo si tiene `getToken` |
| 401 | `invalid_widget_token` | Token del Widget alterado o de otro servidor | Pedí un token nuevo con `POST /api/widget/token` |
| 403 | `center_not_enabled` | Tu credencial no está habilitada para ese `medical_center_id` | Es configuración de nuestro lado: escribinos |
| 503 | `widget_token_not_configured` | Problema de nuestro lado al emitir o validar tokens | Reintentá con backoff |
| 503 | `partner_lookup_unavailable` | No pudimos verificar la credencial en este momento. No es un rechazo | Reintentá con backoff |

### Errores generales

Entrada, centro y procesamiento, con la misma forma que en v1 más un `timestamp`:

```json
{
  "success": false,
  "error": "Report generation failed: ...",
  "error_code": 500,
  "timestamp": "2026-10-14T15:32:10.123456"
}
```

`error_code` suele repetir el status HTTP: ramificá por el status. El código propio que conviene conocer es `1011` (el centro no tiene plantillas), que llega con status `422`. `error` es texto para humanos y puede cambiar.

| Status | Causa | Qué hacer |
|--------|-------|-----------|
| 400 | Falta la entrada (ni `file` ni `transcription_text`); audio de menos de 1KB; la extensión del archivo no coincide con su contenido; formato de audio no soportado; `metadata` que no es JSON o pasa de 10KB; `transcription_text` de más de 50KB; `template_html` o `signed_report` de más de 100KB, o `template_html` vacío tras sanitizar; `report_html` en blanco en refine | Corregí la llamada; no tiene sentido reintentarla igual |
| 403 | Centro deshabilitado | Contactanos |
| 422 | El centro no tiene plantillas cargadas (`error_code` 1011) | Escribinos para cargar las plantillas del centro |
| 500 | Falló la transcripción o la generación del informe, incluido un audio sin dictado (transcripción vacía, p. ej. micrófono muteado) | Revisá la grabación; reintentá con backoff; si persiste, escribinos con el `timestamp` |
| 502 | El modelo devolvió una adenda vacía | Volvé a dictar la adenda |
| 503 | En refine y adenda: el modelo no está disponible en este momento (`error_code` 503) | Reintentá con backoff |

### Errores de validación

Un campo requerido que falta o con un tipo inválido (p. ej. `medical_center_id` sin token del Widget, `report_html` en refine, o `username` en `POST /api/widget/token`) devuelve `422` con `detail` como lista, uno por campo:

```json
{
  "detail": [
    {"type": "missing", "loc": ["body", "medical_center_id"], "msg": "Field required"}
  ]
}
```

## Límites y operación

| Límite | Valor |
|--------|-------|
| Audio (`file`) | Hasta 100 MB; mínimo 1KB |
| Grabación del Widget | 10 minutos |
| `transcription_text` | 50KB |
| `metadata` | 10KB |
| `template_html`, `signed_report` | 100KB |
| Vida del token del Widget | 1 hora |

- **Timeout**: la generación puede tardar decenas de segundos en dictados largos. Configurá un timeout de cliente de 120 segundos o más, que es el timeout del servidor.
- **Rate limit**: no hay un límite por partner publicado. Reintentá los 5xx con backoff exponencial; los 4xx no se reintentan sin corregir la llamada.
- **Créditos**: cada informe generado consume un crédito. Una llamada que falla no consume crédito; una que tuvo éxito y se repite consume otro. Refinar y agregar adendas no consume créditos.
- **Tokens**: pedí un token del Widget por médico y cachealo hasta `expires_at`; no hace falta pedir uno por llamada.

### Health check

Para verificar conectividad desde tu backend. No requiere credencial:

```bash
curl https://api.omniai.com.ar/api/health
```

Respuesta:

```json
{
  "status": "healthy",
  "services": {
    "api": {
      "service": "api",
      "status": "healthy",
      "available": true
    }
  }
}
```

## Alta de un partner

1. **Credencial.** Omnimed AI emite una Partner Credential por partner y la habilita para sus centros. Pedila a omni@omniai.com.ar.
2. **Centros.** Cada centro lo crea Omnimed AI, con su `medical_center_id`, y carga sus plantillas a partir del pack de plantillas que manda el centro.
3. **Médicos.** Los médicos (los `username`) se dan de alta en el panel de administración del centro. Un `username` que el centro no conoce funciona igual, pero solo con las plantillas generales.
4. **Pruebas.** A pedido, habilitamos un centro de prueba para integrar sin tocar datos de un centro real.

Contacto: omni@omniai.com.ar

## Migrar de v1 a v2

| | v1 | v2 |
|--|----|----|
| Autenticación de la API directa | Ninguna: solo `medical_center_id` | `Authorization: Bearer omp_...` + `medical_center_id` + `username` |
| Script del Widget | `/static/widget/v1/omni-widget.min.js` | `/static/widget/v2/omni-widget.min.js` |
| Centro y médico en el Widget | `data-center-id` / `data-username` en la página | Dentro del token, emitido por tu backend |
| Autenticación del Widget | Ninguna | `Authorization: Bearer omw_...` en cada llamada, renovado con `getToken()` |
| Adendas | No | `POST /api/omni-ai/addendum` y `OmniWidget.openAddendum` |
| Errores de credencial | No existen | `{"detail": {"code", "message"}}` |
| Parámetros, respuestas e informes | | Iguales |

### Centros que ya usaban v1

Los centros que ya llamaban sin credencial (v1, solo `medical_center_id`) siguen funcionando así mientras migran. Los centros nuevos tienen que usar una Partner Credential. El acceso sin credencial se cierra centro por centro, con aviso previo por email de al menos 90 días.

### Pasos

1. Pedinos una Partner Credential a omni@omniai.com.ar y guardala como variable de entorno de tu servidor.
2. API directa: agregá el header `Authorization: Bearer omp_...` y mandá `username` con el médico de tu sesión en cada llamada.
3. Widget: sumá un endpoint de tu backend que devuelva el token para el médico de la sesión, cambiá el script a `/static/widget/v2/omni-widget.min.js`, **sacá `data-center-id` / `data-username` del tag** (si quedan, el Widget arranca en modo v1 e ignora tu `init`) y llamá a `OmniWidget.init({ getToken, onReport })`.
4. Manejá los errores por `detail.code` (sobre todo `center_not_enabled` y `partner_credential_revoked`).
5. Probá dictar, refinar y dejar pasar una hora para ver la renovación del token.
6. Si usás adendas: botón propio que llama a `OmniWidget.openAddendum({ signedReport, externalStudyId })` y listener de `omniai:addendum` que la agrega con tu encabezado.

La documentación de v1 sigue en [/api-documentation/v1](https://www.omniai.com.ar/api-documentation/v1).

## Changelog

### v2 (2026-10-14)

- Partner Credential (`omp_...`) para llamadas de servidor a servidor, con `username` como médico declarado.
- Token del Widget por médico (`omw_...`) con `POST /api/widget/token`, y Widget v2 en `/static/widget/v2/omni-widget.min.js` con `getToken`.
- Adendas sobre informes ya firmados: `POST /api/omni-ai/addendum` y `OmniWidget.openAddendum`.
- Errores de credencial con un `detail.code` estable.
- Los parámetros, las respuestas y los informes de v1 no cambian.

### v1

- Widget v1 y API directa autenticados solo por `medical_center_id`. Ver [/api-documentation/v1](https://www.omniai.com.ar/api-documentation/v1).

## Soporte

Consultas, Partner Credentials y alta de centros: omni@omniai.com.ar

---

Source: https://www.omniai.com.ar/api-documentation/v2
Site index for agents: https://www.omniai.com.ar/llms.txt
Sitemap: https://www.omniai.com.ar/sitemap.xml
OpenAPI: https://www.omniai.com.ar/openapi.json
OpenAPI v2: https://www.omniai.com.ar/openapi-v2.json
