# Guía de integración de Omni-AI

> **v1**: autenticación solo por `medical_center_id`. Para integraciones nuevas usá la [v2](https://www.omniai.com.ar/api-documentation/v2), con Partner Credential y Widget con token.

## Descripción general

Omni-AI convierte un dictado médico (audio) en un informe estructurado basado en plantilla, en una sola llamada. Hay dos formas de integrarse:

1. Widget embebible — agregá un tag `<script>` a tu página. El médico graba desde un botón flotante, el informe se devuelve y puede autoinsertarse en tu editor.
2. API directa — llamá a `POST https://api.omniai.com.ar/api/omni-ai` con el archivo de audio y manejá la respuesta vos mismo.

Ambas vías usan el mismo endpoint omni-ai por detrás. Empezá por el Widget salvo que necesites control total de la interfaz de grabación.

## Autenticación

Cada partner de integración recibe un `medical_center_id` (provisto por Omni). El ID del centro identifica las plantillas, el idioma y el estilo de informe usados para generar el resultado. Hoy no se requiere bearer token.

Escribí a omni@omniai.com.ar para solicitar un ID de centro.

## Widget

### Embeber

```html
<script src="https://api.omniai.com.ar/static/widget/v1/omni-widget.min.js"
        data-center-id="YOUR_CENTER_ID"
        data-username="DOCTOR_USERNAME"
        async></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.

`data-username` es opcional. Es el usuario del médico en el centro (el mismo login que usa en la app de escritorio) y decide contra qué plantillas se matchea el dictado:

| `data-username` | Plantillas que se matchean |
|-----------------|----------------------------|
| ausente | 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 |

### Autoinsertar el informe

El widget emite un evento `omniai:report` cuando el informe está listo, y de nuevo cada vez que el médico hace clic en "Insertar". `event.detail` trae `html`, `text` y el `recordingId` del dictado; si autoinsertás, reemplazá lo que ya insertaste para ese `recordingId` en lugar de agregar una segunda copia. Escuchalo y pegá el HTML (o el texto plano) en tu editor:

```javascript
window.addEventListener('omniai:report', (event) => {
  const { html, text } = event.detail;
  // p. ej. pegar en el campo contenteditable / rich-text con foco
  document.execCommand('insertHTML', false, html);
});
```

O inicializalo manualmente con un callback:

```javascript
OmniWidget.init({
  centerId: 'YOUR_CENTER_ID',
  username: 'DOCTOR_USERNAME', // opcional, mismas reglas que data-username
  onReport: ({ html, text, recordingId }) => {
    // Insertar en tu editor de informes
  },
  onAudio: (audio) => {
    // Opcional: guardar tu propia copia de la grabación (ver abajo)
  }
});
```

### 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"` o `"refinement"` (una corrección por voz) |
| recordingId | ID único de esta grabación. El payload de `omniai:report` trae el `recordingId` del dictado, que vincula audio e informe |
| parentRecordingId | En un refinamiento, el `recordingId` del dictado que corrige; `null` en dictados |
| recordedAt | Timestamp ISO 8601 |

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

### 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; el médico puede volver a grabar |

### Requisitos del navegador

- HTTPS (requerido para acceder al micrófono)
- Permiso otorgado para `navigator.mediaDevices.getUserMedia`
- Duración máxima de grabación: 10 minutos (configurable)

---

## API directa

### Generar un informe

```
POST https://api.omniai.com.ar/api/omni-ai
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). 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í | Identificador del centro provisto por Omni |
| user_id | String | No | Identificador del médico/usuario para analítica |
| username | String | No | Usuario del médico en el centro. El matching usa las plantillas visibles para ese médico (generales + asignadas). Ausente o desconocido: solo generales. Se ignora si se envía `template_html` |
| language | String | No | Se acepta por compatibilidad y se ignora: el dictado y el informe usan el idioma del centro (`medical_center_id`) |
| 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`, la detección del estudio es automática a partir de la transcripción y la plantilla se matchea contra 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 saltea el paso de transcripción y el texto entra al pipeline exactamente donde entraría la transcripción: la detección del estudio, el matching de plantillas y la generación se comportan igual. Se combina naturalmente con `template_html` (sin transcripción *y* sin matching — 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` refleja tu parámetro `language`, 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 el matching de plantillas se **saltean 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 cae al camino normal de matching.
- 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

```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": 1.0,
        "report_html": "<p>...</p>",
        "report_rtf": null,
        "transcription_segment": "...",
        "template_html": null
      }
    ],
    "warnings": [],
    "context_checks": {
      "laterality": {"status": "ok"},
      "modality": {"status": "skipped", "reason": "not_dictated"},
      "region": {"status": "ok"}
    },
    "processing_time_ms": 7441,
    "metadata": {
      "study_id": 12345,
      "detection_source": "preselected_template",
      "input_source": "audio",
      "is_multi_study": false,
      "...": "..."
    }
  }
}
```

> **Tip**: Guardá `data.metadata.study_id` si planeás refinar este informe más adelante.

#### 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

**cURL**

```bash
curl -X POST https://api.omniai.com.ar/api/omni-ai \
  -F "file=@recording.m4a" \
  -F "medical_center_id=YOUR_CENTER_ID" \
  -F "user_id=dr_martinez" \
  -F "language=spa"
```

**cURL (plantilla preseleccionada)**

```bash
curl -X POST https://api.omniai.com.ar/api/omni-ai \
  -F "file=@recording.m4a" \
  -F "medical_center_id=YOUR_CENTER_ID" \
  -F "template_html=<h2>IRM de Rodilla</h2><p>...</p>" \
  -F "template_name=IRM de Rodilla" \
  -F "include_template=true"
```

**Python**

```python
import requests

url = "https://api.omniai.com.ar/api/omni-ai"

with open("audio.m4a", "rb") as f:
    response = requests.post(
        url,
        files={"file": f},
        data={
            "medical_center_id": "YOUR_CENTER_ID",
            "user_id": "dr_lopez",
            "language": "spa",
        },
    )

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

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

**JavaScript (fetch)**

```javascript
async function processAudio(audioFile, centerId, userId) {
  const form = new FormData();
  form.append('file', audioFile);
  form.append('medical_center_id', centerId);
  form.append('user_id', userId);
  form.append('language', 'spa');

  const res = await fetch('https://api.omniai.com.ar/api/omni-ai', {
    method: 'POST',
    body: form,
  });

  if (!res.ok) {
    const err = await res.json().catch(() => ({}));
    throw new Error(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
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í | Identificador del centro provisto por Omni |
| 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",
      "model_used": "gemini-2.5-flash",
      "...": "..."
    }
  }
}
```

- `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 \
  -F "file=@correction.wav" \
  -F "medical_center_id=YOUR_CENTER_ID" \
  -F "report_html=<p>...último informe generado...</p>" \
  -F "template_html=<p>...plantilla original...</p>" \
  -F "study_id=12345"
```

### Respuesta de error

Los errores devuelven un status no-2xx con:

```json
{
  "success": false,
  "error": "Human-readable error message",
  "error_code": 400
}
```

`error_code` suele ser el status HTTP. Algunos errores traen un código más específico (p. ej. `1011`: el centro no tiene plantillas; `1006`: el modelo de informes no está disponible).

| Código | Causa | Solución |
|--------|-------|----------|
| 400 | Falta la entrada (ni file ni transcription_text), formato de audio no soportado, metadata JSON malformada, o template_html demasiado grande | Enviá wav/mp3/m4a/ogg/flac/webm o transcription_text (máx 50KB); validá el JSON bajo 10KB; mantené template_html bajo 100KB |
| 403 | Centro deshabilitado / no provisionado | Contactá a Omni para habilitar tu `medical_center_id` |
| 422 | El centro no tiene plantillas cargadas (`error_code` 1011) | Contactá a Omni para cargar las plantillas de tu centro |
| 500 | Falló la transcripción o la generación del informe (p. ej. audio en silencio / micrófono muteado) | Revisá la grabación; reintentá con backoff; contactá a soporte si persiste |
| 503 | El modelo de generación de informes no está disponible momentáneamente (`error_code` 1006) | Reintentá con backoff |

---

## 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.

## Health check

Usá este endpoint para verificar conectividad desde tu backend:

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

Respuesta:

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

## Soporte

Consultas y aprovisionamiento: omni@omniai.com.ar

---

Source: https://www.omniai.com.ar/api-documentation/v1
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
