# Guía de integración de OmniMed Consulta (v1)

OmniMed Consulta procesa la grabación completa de una consulta médica (médico, paciente y acompañantes) y devuelve, en un único JSON:

- la **nota** de la consulta en el formato elegido (SOAP por defecto), donde cada oración cita el momento de la conversación del que sale;
- los **pedidos** que hizo el médico (laboratorio, imágenes, otros estudios, derivaciones, terapias, procedimientos), cada uno con un **borrador de orden** listo para completar en tu plataforma;
- las **sugerencias** para el médico (diagnósticos para considerar con razones a favor y en contra, controles de seguridad de los pedidos, chequeos de guías clínicas), que nunca cambian la nota por sí solas;
- los **códigos CIE-10** propuestos para la evaluación;
- la **transcripción** con el rol de cada hablante y sus tiempos.

Si enviás la historia clínica del paciente, el resultado la usa como contexto: trae los antecedentes relevantes citados y marca las **discrepancias** entre lo dicho hoy y lo registrado (por ejemplo, "no toma medicación" frente a un tratamiento activo). La nota sale solo de lo dicho en la consulta de hoy: la historia nunca entra en sus secciones como si se hubiera dicho.

Las decisiones del médico (aceptar una sugerencia, editar la nota, firmar las órdenes) ocurren en tu sistema, no en el nuestro.

El contrato completo está en [OpenAPI 3.1](https://www.omniai.com.ar/openapi-consulta-v1.json).

## Inicio rápido

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

1. **Pedí tu Partner Credential (credencial de partner)** a santiago@omniai.com.ar, habilitada para Consulta en tus centros. Es un secreto `omp_...`: guardalo como variable de entorno de tu servidor. Si ya integrás el dictado de radiología, la misma credencial sirve cuando el centro tiene Consulta habilitada.
2. **Enviá la consulta.** Desde tu servidor, con la grabación y el consentimiento del paciente:

   ```bash
   curl -X POST https://api.omniai.com.ar/api/consulta/v1/consultations \
     -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
     -H "Idempotency-Key: 6f1c2a4e-0b9d-4d61-a3f2-7c5e8b1d9a20" \
     -F "audio=@consulta.m4a" \
     -F "medical_center_id=YOUR_CENTER_ID" \
     -F "doctor_id=med-1042" \
     -F "consent=true"
   ```

   Responde `202` enseguida, con el `id` de la consulta y `status: "processing"`.
3. **Consultá el resultado** hasta que `status` sea `done` (o `failed`):

   ```bash
   curl https://api.omniai.com.ar/api/consulta/v1/consultations/3f6c1d2e-8a4b-4c7e-9f10-2b5d6e7a8c90 \
     -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL"
   ```

4. **Mostrá el resultado** en tu sistema: la nota en `note`, los pedidos en `orders`, las sugerencias en `suggestions` (ver [El resultado, campo por campo](#el-resultado-campo-por-campo)).

El procesamiento es asíncrono porque una consulta larga tarda más de lo que una conexión HTTP espera.

## Autenticación

### Partner Credential (servidor a servidor)

Cada llamada lleva la **Partner Credential** como Bearer:

```
Authorization: Bearer omp_...
```

- Es un secreto de servidor a servidor: nunca debe llegar al navegador ni a una app instalada. Guardala en una variable de entorno o en un gestor de secretos.
- Está habilitada para una lista de centros y, en cada centro, para los productos contratados. Una credencial de Dictado no sirve para Consulta salvo que el centro tenga Consulta habilitada para esa credencial.
- Cada llamada que crea una consulta nombra el centro con `medical_center_id` (su id o su slug en Omnimed AI).
- Esta API no tiene un token para el navegador: todas las llamadas salen de tu servidor.

### Rotación

Si la credencial se filtra, pedinos rotarla a santiago@omniai.com.ar: la credencial vieja deja de funcionar en menos de 60 segundos. Una credencial rotada o revocada responde `401` con `partner_credential_revoked`.

## Médico declarado

Tu sistema ya autenticó al médico; en cada consulta nos decís quién es. No creamos usuarios ni le pedimos al médico que inicie sesión con nosotros: confiamos en tu palabra dentro de los centros para los que tu credencial está habilitada.

| Campo | Requerido | Máximo | Descripción |
|-------|-----------|--------|-------------|
| `doctor_id` | Sí | 255 caracteres | Tu identificador del médico que grabó la consulta |
| `doctor_name` | No | 255 caracteres | Nombre del médico, como debe figurar en el borrador de orden |
| `doctor_license` | No | 100 caracteres | Matrícula del médico. Sin ella, el borrador de orden la marca como faltante (`missing` incluye `doctor_license`) |
| `doctor_specialty` | No | 255 caracteres | Especialidad del médico |

Los cuatro vuelven en la consulta como `doctor` (`id`, `name`, `license`, `specialty`). Un campo que supera su largo máximo responde `422` con `invalid_doctor`.

Decidí el médico 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.

## Datos del paciente

No hace falta identificar al paciente. Podés enviar en el campo `patient` (JSON) datos opcionales que mejoran el resultado y una referencia opaca tuya para correlacionar:

| Campo | Tipo | Descripción |
|-------|------|-------------|
| `ref` | String (hasta 255) | Referencia opaca de tu sistema (por ejemplo, el número de historia clínica). Vuelve como `patient_ref`; no la usamos para unir consultas |
| `birth_date` | Fecha (`AAAA-MM-DD`) | Fecha de nacimiento. Si la enviás, se usa en lugar de `age` |
| `age` | Entero, 0 a 130 | Edad en años |
| `sex` | `female`, `male`, `other`, `unknown` | Sexo |
| `coverage.name` | String | Obra social o prepaga |
| `coverage.plan` | String | Plan |
| `coverage.affiliate_number` | String | Número de afiliado |

```json
{"ref": "hc-998877", "birth_date": "1988-04-12", "sex": "female", "coverage": {"name": "Obra social ejemplo", "plan": "210"}}
```

- **Sin identidad.** No mandes nombre ni documento: los datos de identidad del paciente los completa tu sistema sobre el borrador de orden.
- **Tolerante.** Los campos que no conocemos se ignoran, y también un campo conocido cuyo valor no respeta el formato (por ejemplo `sex: "F"`, `age: 200` o una fecha mal escrita): se descarta ese campo y se usa el resto. Guardamos solo los campos de esta lista.
- Hasta 1 MB. Un `patient` que no es JSON, no es un objeto o supera 1 MB responde `422` con `invalid_patient`.

## Historia clínica

Opcional, en el campo `history` (JSON), organizada por secciones. Todas las secciones son opcionales. Cada registro lleva tu propio `id`: las citas del resultado (`source_ids`) usan esos mismos ids, así podés enlazar cada antecedente o discrepancia al registro de tu sistema.

| Sección | Campos requeridos | Campos opcionales |
|---------|-------------------|-------------------|
| `problems` | `id`, `text` (el problema, como lo lista tu sistema, ej. "Hipertensión arterial") | `status` (`active` o `resolved`; por defecto `active`), `code` (código con su sistema, ej. "CIE-10 I10"), `onset` (fecha) |
| `allergies` | `id`, `substance` | `reaction` |
| `medications` | `id`, `name` | `dose` (dosis y frecuencia en texto, ej. "10 mg cada 12 h"), `status` (`active` o `suspended`; por defecto `active`), `since` (fecha) |
| `labs` | `id`, `name`, `value`, `date` | `unit`, `ref_range` (rango de referencia en texto, ej. "70-110") |
| `imaging_reports` | `id`, `study`, `date`, `text` (texto del informe) | `conclusion` |
| `visits` (consultas previas) | `id`, `date` | `reason`, `summary` (resumen o evolución de esa consulta) |

Las fechas van como `AAAA-MM-DD`.

```json
{
  "problems": [{"id": "pb-3", "text": "Hipertensión arterial", "code": "CIE-10 I10", "onset": "2019-05-01"}],
  "allergies": [{"id": "alg-17", "substance": "Penicilina", "reaction": "Urticaria"}],
  "medications": [{"id": "med-8", "name": "Enalapril", "dose": "10 mg cada 12 h", "since": "2019-05-01"}],
  "labs": [{"id": "lab-51", "name": "Glucemia", "value": "98", "unit": "mg/dL", "ref_range": "70-110", "date": "2026-08-20"}],
  "imaging_reports": [
    {"id": "img-4", "study": "Ecografía abdominal", "date": "2025-03-10", "text": "Hígado de forma y tamaño conservados...", "conclusion": "Sin hallazgos patológicos."}
  ],
  "visits": [{"id": "vis-22", "date": "2026-08-20", "reason": "Control de presión", "summary": "Buen control con enalapril."}]
}
```

- La historia que enviás se usa solo para esa consulta: nunca se mezcla con otra consulta ni con otro paciente.
- Hasta 1 MB de JSON. Los textos de más de 8.000 caracteres se recortan.
- Responde `422` con `invalid_history` si no es JSON, supera 1 MB o un registro no respeta su formato: campos requeridos, fechas, valores permitidos o ids repetidos dentro de una sección.

## Consentimiento

Grabar una consulta requiere el consentimiento del paciente. Al enviar `consent=true` tu sistema declara que el paciente aceptó ser grabado. Sin eso la consulta se rechaza con `422` y `consent_required`.

## Endpoints

Todas las llamadas llevan `Authorization: Bearer omp_...` y salen de tu servidor.

| Método | Ruta | Para qué |
|--------|------|----------|
| `POST` | `/api/consulta/v1/consultations` | Enviar una consulta para procesar |
| `GET` | `/api/consulta/v1/consultations/{consultation_id}` | Obtener el estado y el resultado |
| `POST` | `/api/consulta/v1/consultations/{consultation_id}/instructions` | Generar indicaciones para el paciente |
| `GET` | `/api/consulta/v1/note-formats` | Listar los formatos de nota |

### Enviar una consulta

```
POST https://api.omniai.com.ar/api/consulta/v1/consultations
Authorization: Bearer omp_...
Idempotency-Key: <UUID de este envío>
Content-Type: multipart/form-data
```

Recibe la grabación completa y responde de inmediato con el `id`; el procesamiento sigue en segundo plano.

#### Parámetros

| Dónde | Campo | Tipo | Requerido | Descripción |
|-------|-------|------|-----------|-------------|
| Header | `Idempotency-Key` | String (hasta 255) | No, recomendado | Identificador único de este envío, generado por tu sistema (recomendado un UUID). Ver [Idempotencia](#idempotencia) |
| Form | `audio` | Archivo | Sí | La grabación completa de la consulta (médico, paciente y acompañantes) |
| Form | `dictation` | Archivo | No | Lo que el médico dicta solo, después de que el paciente se fue. Se suma a la misma consulta; si contradice a la conversación, prevalece el dictado |
| Form | `medical_center_id` | String | Sí | El centro por el que actuás (su id o su slug en Omnimed AI). Tiene que estar habilitado para Consulta en tu credencial |
| Form | `doctor_id` | String (hasta 255) | Sí | Tu identificador del médico. Ver [Médico declarado](#medico-declarado) |
| Form | `doctor_name` | String (hasta 255) | No | Nombre del médico, como debe figurar en el borrador de orden |
| Form | `doctor_license` | String (hasta 100) | No | Matrícula del médico |
| Form | `doctor_specialty` | String (hasta 255) | No | Especialidad del médico |
| Form | `consent` | Boolean | Sí | Tiene que ser `true`. Declara que el paciente aceptó ser grabado |
| Form | `note_format` | String | No | Uno de los `id` de [`GET /api/consulta/v1/note-formats`](#listar-los-formatos-de-nota). Por defecto `soap` |
| Form | `num_speakers` | Entero, 1 a 10 | No | Cantidad esperada de personas que hablan. Si no se envía, se detecta sola |
| Form | `patient` | JSON | No | Ver [Datos del paciente](#datos-del-paciente) |
| Form | `history` | JSON | No | Ver [Historia clínica](#historia-clinica) |

`audio` y `dictation` aceptan m4a, mp3, wav, webm y ogg, hasta 100 MB cada uno. Mandá `patient` e `history` como partes JSON del formulario (`type=application/json` en curl).

#### Respuesta `202`: consulta aceptada

```json
{
  "id": "3f6c1d2e-8a4b-4c7e-9f10-2b5d6e7a8c90",
  "status": "processing",
  "stage": "transcribing",
  "medical_center_id": "9b2f4c1a-6d3e-4f5a-8b7c-1d2e3f4a5b6c",
  "doctor": {"id": "med-1042", "name": "Dra. Ana Pérez", "license": "MP 12345", "specialty": "Clínica médica"},
  "patient_ref": "hc-998877",
  "note_format": "soap",
  "created_at": "2026-11-03T13:05:12Z",
  "completed_at": null
}
```

- Guardá el `id`: es lo que vas a consultar.
- `medical_center_id` viene siempre como id del centro, aunque lo hayas enviado por su slug.
- Un reenvío con una `Idempotency-Key` ya usada responde `200` con la consulta existente, con la misma forma; si esa consulta había fallado o estaba trabada, se reprocesa y responde `202` con el mismo `id` (ver [Idempotencia](#idempotencia)).

#### Ejemplo completo

Con dictado posterior, datos del médico, datos del paciente e historia clínica:

```bash
curl -X POST https://api.omniai.com.ar/api/consulta/v1/consultations \
  -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
  -H "Idempotency-Key: 6f1c2a4e-0b9d-4d61-a3f2-7c5e8b1d9a20" \
  -F "audio=@consulta.m4a" \
  -F "dictation=@dictado-posterior.m4a" \
  -F "medical_center_id=YOUR_CENTER_ID" \
  -F "doctor_id=med-1042" \
  -F "doctor_name=Dra. Ana Pérez" \
  -F "doctor_license=MP 12345" \
  -F "doctor_specialty=Clínica médica" \
  -F "consent=true" \
  -F "note_format=soap" \
  -F 'patient={"ref": "hc-998877", "age": 38, "sex": "female"};type=application/json' \
  -F "history=@historia.json;type=application/json"
```

En Python:

```python
import json
import os
import uuid

import requests

API = "https://api.omniai.com.ar"
headers = {
    "Authorization": f"Bearer {os.environ['OMNI_PARTNER_CREDENTIAL']}",
    "Idempotency-Key": str(uuid.uuid4()),  # guardala para reintentar con la misma
}

with open("consulta.m4a", "rb") as audio:
    response = requests.post(
        f"{API}/api/consulta/v1/consultations",
        headers=headers,
        files={
            "audio": ("consulta.m4a", audio, "audio/mp4"),
            "patient": (None, json.dumps({"ref": "hc-998877", "age": 38}), "application/json"),
        },
        data={
            "medical_center_id": "YOUR_CENTER_ID",
            "doctor_id": "med-1042",
            "consent": "true",
        },
        timeout=120,
    )

response.raise_for_status()
consultation_id = response.json()["id"]
```

### Obtener el estado y el resultado

```
GET https://api.omniai.com.ar/api/consulta/v1/consultations/{consultation_id}
Authorization: Bearer omp_...
```

`consultation_id` es el `id` que devolvió el envío.

#### Estados

| `status` | Qué significa | Qué hacer |
|----------|---------------|-----------|
| `processing` | Se está procesando. `stage` dice en qué paso va y los campos del resultado vienen vacíos | Seguí consultando |
| `done` | Terminó. Trae todo el resultado | Mostralo en tu sistema |
| `failed` | Falló. `error` dice por qué | Reenviá con la misma `Idempotency-Key` |

Mientras `status` es `processing`, `stage` va pasando por `transcribing`, `identifying_speakers` y `writing_note`; al terminar vale `done` o `failed`. La lista de pasos puede crecer: tratá un valor que no conozcas como "procesando".

Con `failed`, `error` trae uno de estos códigos:

| `error.code` | Qué significa | `error.message` |
|--------------|---------------|-----------------|
| `audio_empty` | La grabación no tiene voz | "La grabación no tiene voz. Revisá que el micrófono no haya estado silenciado." |
| `processing_failed` | El procesamiento falló | "No se pudo procesar la consulta; reenviala con la misma Idempotency-Key." |
| `processing_timeout` | El procesamiento llevaba más de 10 minutos sin avanzar | "El procesamiento se interrumpió; reenviala con la misma Idempotency-Key." |

Un código que no conozcas trae el mensaje "No se pudo procesar la consulta.". Compará por `error.code`; `error.message` es para mostrarle al médico y puede cambiar.

```json
{
  "id": "3f6c1d2e-8a4b-4c7e-9f10-2b5d6e7a8c90",
  "status": "failed",
  "stage": "failed",
  "error": {"code": "audio_empty", "message": "La grabación no tiene voz. Revisá que el micrófono no haya estado silenciado."},
  "...": "..."
}
```

En los tres casos podés reenviar con la misma `Idempotency-Key`: la consulta se procesa de nuevo con el audio de ese envío y responde `202` con el mismo `id`.

#### Cada cuánto consultar

Recomendamos consultar cada 5 segundos durante el primer minuto y después cada 15 segundos. El tiempo de procesamiento depende de la duración del audio; no hay un tiempo garantizado.

```python
import time

def wait_for(consultation_id: str) -> dict:
    started = time.monotonic()
    while True:
        response = requests.get(
            f"{API}/api/consulta/v1/consultations/{consultation_id}",
            headers={"Authorization": f"Bearer {os.environ['OMNI_PARTNER_CREDENTIAL']}"},
            timeout=30,
        )
        response.raise_for_status()
        consultation = response.json()
        if consultation["status"] != "processing":
            return consultation  # "done" o "failed"
        time.sleep(5 if time.monotonic() - started < 60 else 15)
```

#### Respuesta `200` con `status: "done"`

```json
{
  "id": "3f6c1d2e-8a4b-4c7e-9f10-2b5d6e7a8c90",
  "status": "done",
  "stage": "done",
  "error": null,
  "medical_center_id": "9b2f4c1a-6d3e-4f5a-8b7c-1d2e3f4a5b6c",
  "doctor": {"id": "med-1042", "name": "Dra. Ana Pérez", "license": "MP 12345", "specialty": "Clínica médica"},
  "patient_ref": "hc-998877",
  "note_format": "soap",
  "created_at": "2026-11-03T13:05:12Z",
  "completed_at": "2026-11-03T13:06:40Z",
  "audio_seconds": 312.4,
  "note": {
    "format": "soap",
    "sections": [
      {
        "key": "subjective",
        "title": "Subjetivo",
        "sentences": [
          {"text": "Dolor abdominal de 2 días de evolución, con diarrea, sin vómitos.", "turn_ids": ["t2", "t4"]},
          {"text": "Refiere cefalea leve.", "turn_ids": ["t4"]}
        ]
      },
      {
        "key": "objective",
        "title": "Objetivo",
        "sentences": [{"text": "Dolor que se irradia a fosa ilíaca derecha.", "turn_ids": ["t8"]}]
      },
      {
        "key": "assessment",
        "title": "Evaluación",
        "sentences": [{"text": "Se sospecha cuadro quirúrgico a descartar.", "turn_ids": ["t9"]}]
      },
      {
        "key": "plan",
        "title": "Plan",
        "sentences": [
          {"text": "Laboratorio con enzimas y ecografía abdominal; control con resultados.", "turn_ids": ["t9"]},
          {"text": "Pautas de alarma: fiebre alta, dolor abdominal intenso o abdomen duro.", "turn_ids": ["t11"]}
        ]
      }
    ],
    "history_context": [{"text": "Alergia a penicilina registrada.", "source_ids": ["alg-17"]}],
    "discrepancies": []
  },
  "orders": [
    {
      "id": "o1",
      "kind": "lab",
      "name": "Laboratorio",
      "said_as": "te vamos a sacar sangre, quiero ver si tenés elevadas algunas enzimas",
      "details": "Enzimas",
      "indication": "Descartar cuadro quirúrgico",
      "status": "firm",
      "needs_detail": true,
      "detail_question": "¿Qué determinaciones?",
      "detail_options": ["Hemograma", "Hepatograma", "Amilasa", "Lipasa"],
      "turn_ids": ["t9"],
      "missing": ["detail"],
      "draft": {
        "study": "Laboratorio, Enzimas",
        "presumptive_diagnosis": "Dolor abdominal, se sospecha cuadro quirúrgico",
        "clinical_data": "Dolor abdominal de 2 días con irradiación a fosa ilíaca derecha.",
        "cie10_codes": ["R10.3"]
      }
    },
    {
      "id": "o2",
      "kind": "imaging",
      "name": "Ecografía abdominal",
      "said_as": "vamos a hacer un eco rápido como para descartar que no sea el apéndice",
      "details": "",
      "indication": "Descartar apendicitis",
      "status": "firm",
      "needs_detail": false,
      "detail_question": "",
      "detail_options": [],
      "turn_ids": ["t9"],
      "missing": [],
      "draft": {
        "study": "Ecografía abdominal",
        "presumptive_diagnosis": "Dolor abdominal, se sospecha cuadro quirúrgico",
        "clinical_data": "Dolor abdominal de 2 días con irradiación a fosa ilíaca derecha.",
        "cie10_codes": ["R10.3"]
      }
    }
  ],
  "suggestions": [
    {
      "id": "s1",
      "kind": "diagnosis_to_consider",
      "severity": "info",
      "title": "Gastroenteritis aguda",
      "body": "",
      "question": "",
      "order_id": null,
      "proposed_actions": [
        {"key": "add_to_assessment", "label": "Agregar a la evaluación"},
        {"key": "dismiss", "label": "Descartar"}
      ],
      "reasons_for": [{"text": "Diarrea de 2 días", "turn_ids": ["t4"]}],
      "reasons_against": [{"text": "Dolor que se irradia a fosa ilíaca derecha", "turn_ids": ["t8"]}],
      "sources": {"turn_ids": ["t4", "t8"], "source_ids": [], "guideline": null},
      "ai_generated": true
    }
  ],
  "diagnosis_codes": [{"code": "R10.3", "description": "Dolor localizado en otras partes inferiores del abdomen"}],
  "transcript": {
    "turns": [
      {"id": "t1", "speaker_id": "speaker_0", "role": "doctor", "segment": "conversation", "text": "Hola, ¿cómo andás?", "start": 0.4, "end": 1.6},
      {"id": "t2", "speaker_id": "speaker_1", "role": "patient", "segment": "conversation", "text": "Vengo porque tengo dolor de panza hace dos días.", "start": 1.8, "end": 5.2}
    ]
  },
  "patient_instructions": null
}
```

La transcripción del ejemplo está recortada a sus dos primeros turnos. Cada campo se explica en [El resultado, campo por campo](#el-resultado-campo-por-campo).

### Generar indicaciones para el paciente

```
POST https://api.omniai.com.ar/api/consulta/v1/consultations/{consultation_id}/instructions
Authorization: Bearer omp_...
```

Sin cuerpo. Reescribe el Plan de la nota en lenguaje simple para que el paciente se lo lleve (por ejemplo, por WhatsApp o impreso). No agrega nada que no esté en la nota.

- Solo con una consulta en `done`; antes responde `409` con `consultation_not_done`.
- Si la nota no tiene Plan del que partir, responde `422` con `plan_empty`.
- Llamarlo de nuevo genera una versión nueva, que reemplaza a la anterior en `patient_instructions` de la consulta.
- No cuenta como consulta.

#### Respuesta `200`

```json
{
  "patient_instructions": "Vas a hacerte un análisis de sangre y una ecografía de abdomen. Cuando tengas los resultados, volvé a la consulta.\nConsultá a la guardia si tenés fiebre alta, dolor fuerte en la panza o si la panza se pone dura de golpe.\n"
}
```

`patient_instructions` es texto plano, en segunda persona, para el paciente.

#### Ejemplo

```bash
curl -X POST https://api.omniai.com.ar/api/consulta/v1/consultations/3f6c1d2e-8a4b-4c7e-9f10-2b5d6e7a8c90/instructions \
  -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL"
```

### Listar los formatos de nota

```
GET https://api.omniai.com.ar/api/consulta/v1/note-formats
Authorization: Bearer omp_...
```

Los formatos en los que se puede escribir la nota, con sus secciones en orden. El `id` de cada formato es el valor de `note_format` al enviar la consulta, y las `key` de sus secciones son las de `note.sections` en el resultado.

#### Respuesta `200`

```json
{
  "formats": [
    {
      "id": "soap",
      "name": "SOAP",
      "sections": [
        {"key": "subjective", "title": "Subjetivo"},
        {"key": "objective", "title": "Objetivo"},
        {"key": "assessment", "title": "Evaluación"},
        {"key": "plan", "title": "Plan"}
      ]
    },
    {
      "id": "pediatric_checkup",
      "name": "Control pediátrico",
      "sections": [
        {"key": "reason", "title": "Motivo de consulta"},
        {"key": "growth", "title": "Crecimiento y desarrollo"},
        {"key": "feeding", "title": "Alimentación y hábitos"},
        {"key": "vaccines", "title": "Vacunas"},
        {"key": "exam", "title": "Examen físico"},
        {"key": "plan", "title": "Indicaciones"}
      ]
    }
  ]
}
```

## Idempotencia

Una red que se corta a mitad de un envío de 15 MB deja la duda de si la consulta llegó. Con `Idempotency-Key` podés reenviar sin crear otra consulta ni pagarla dos veces.

- Generá un identificador único por consulta (recomendado un UUID, hasta 255 caracteres) y guardalo junto a la consulta en tu sistema antes de enviar.
- **Reenvío con la misma key:**
  - si la primera sigue en proceso o terminó, responde `200` con esa misma consulta;
  - si falló, o lleva más de 10 minutos sin avanzar, se procesa de nuevo con el audio de este envío y responde `202` con el mismo `id`: seguí consultando ese `id`.
- **Fallida o trabada:** para reintentar una consulta en `failed` (o una que no avanza), reenviala con la misma key.
- **Keys por credencial:** cada credencial tiene sus propias keys. Una key que ya se usó para otro centro responde `409` con `idempotency_key_conflict`.
- **Sin key**, cada envío crea una consulta nueva.
- Una key de más de 255 caracteres responde `422` con `invalid_idempotency_key`.

## El resultado, campo por campo

### Consulta

| Campo | Tipo | Descripción |
|-------|------|-------------|
| `id` | UUID | Identificador de la consulta |
| `status` | `processing`, `done`, `failed` | Estado. Ver [Estados](#estados) |
| `stage` | String | Paso actual: `transcribing`, `identifying_speakers`, `writing_note`, `done`, `failed`. Puede crecer; tratá un valor desconocido como "procesando" |
| `error` | Objeto o `null` | Solo con `failed`: `{"code", "message"}` |
| `medical_center_id` | String | El id del centro, aunque lo hayas enviado por su slug |
| `doctor` | Objeto | El médico declarado: `id`, `name`, `license`, `specialty` (estos tres pueden ser `null`) |
| `patient_ref` | String o `null` | El `ref` que mandaste en `patient` |
| `note_format` | String | El formato de la nota |
| `created_at` | Fecha y hora | Cuándo se recibió |
| `completed_at` | Fecha y hora o `null` | Cuándo terminó |
| `audio_seconds` | Número o `null` | Duración del audio procesado |
| `note` | Objeto o `null` | La [nota](#nota-note) |
| `orders` | Lista | Los [pedidos](#pedidos-orders) |
| `suggestions` | Lista | Las [sugerencias](#sugerencias-suggestions) |
| `diagnosis_codes` | Lista | Los [códigos CIE-10](#codigos-cie-10-diagnosis-codes) |
| `transcript` | Objeto o `null` | La [transcripción](#transcripcion-transcript) |
| `patient_instructions` | String o `null` | Las últimas indicaciones generadas con `POST .../instructions`, si las hay |

Parseá la respuesta de forma tolerante: pueden sumarse campos nuevos.

### Nota (`note`)

| Campo | Descripción |
|-------|-------------|
| `format` | El formato de la nota (`soap`, `pediatric_checkup`, ...) |
| `sections` | Las secciones del formato, en orden. Cada una tiene `key`, `title` y `sentences` |
| `sections[].sentences[]` | Cada oración: `text` y `turn_ids`, los turnos de `transcript.turns` de los que sale. Cada oración sale de lo dicho hoy |
| `history_context` | Antecedentes de la historia enviada que son relevantes hoy: `text` y `source_ids`, los `id` de tus registros en `history`. Vacío sin historia |
| `discrepancies` | Lo dicho hoy que contradice la historia enviada: `text`, `turn_ids` (el turno) y `source_ids` (el registro). Nunca se resuelve sola: la resuelve el médico |

`history_context` y `discrepancies` van aparte de `sections`: la historia nunca entra en la nota como si se hubiera dicho hoy.

### Pedidos (`orders`)

Un pedido es algo que indicó el médico, citado a sus propias palabras. Lo que pide el paciente sin que el médico lo indique no es un pedido.

| Campo | Descripción |
|-------|-------------|
| `id` | Identificador del pedido dentro de la consulta (lo cita `suggestions[].order_id`) |
| `kind` | `lab` (laboratorio), `imaging` (imágenes), `other_study` (otro estudio), `referral` (derivación), `therapy` (terapia), `procedure` (procedimiento) |
| `name` | Nombre clínico, como en una orden (ej. "Ecografía abdominal") |
| `said_as` | Las palabras textuales del médico |
| `details` | Lateralidad, región, contraste, determinaciones, sesiones; solo si se dijeron |
| `indication` | El motivo del pedido, solo si el médico lo dijo |
| `status` | `firm`: lo pide ahora. `conditional`: depende de algo futuro ("si sale positivo..."). `in_visit`: se hace durante la consulta |
| `needs_detail` | `true` si falta algo imprescindible para emitir la orden |
| `detail_question` | Qué falta, como pregunta corta para el médico (ej. "¿Qué determinaciones?") |
| `detail_options` | Entre 2 y 4 respuestas posibles a `detail_question`, para mostrar como opciones |
| `turn_ids` | Los turnos donde el médico lo pidió |
| `missing` | Lo que falta para que el borrador de orden esté completo: `detail`, `diagnosis`, `clinical_data`, `doctor_license` |
| `draft` | El [borrador de orden](#borrador-de-orden-draft). Solo para pedidos `firm`; si no, `null` |

#### Borrador de orden (`draft`)

Lo que una plataforma de órdenes necesita, armado a partir del pedido y la nota. Nunca es una orden válida por sí misma: el médico la completa y la firma en tu sistema, y los datos del paciente (nombre, documento, cobertura) los agrega tu sistema.

| Campo | Descripción |
|-------|-------------|
| `study` | Estudio con sus detalles |
| `presumptive_diagnosis` | Diagnóstico presuntivo, de la evaluación de la nota; `null` si falta |
| `clinical_data` | Datos clínicos que justifican el pedido; `null` si faltan |
| `cie10_codes` | Hasta 2 códigos CIE-10 |

### Sugerencias (`suggestions`)

Una sugerencia es una propuesta para el médico sobre la consulta de hoy. No cambia nada por sí sola: el médico decide en tu sistema. Cada una explica por qué y cita sus fuentes.

| `kind` | Qué propone |
|--------|-------------|
| `diagnosis_to_consider` | Un diagnóstico para considerar, con razones a favor y en contra |
| `safety` | Un pedido contraindicado para este paciente (ej. contraste tras una reacción previa) |
| `duplicate` | El mismo estudio ya figura en la historia enviada |
| `low_value` | Una guía desaconseja ese pedido en esta situación |
| `missing_check` | Un chequeo de una guía clínica que no aparece en la consulta |
| `unrecorded_problem` | Algo dicho hoy que no figura como problema en la historia |
| `pending_follow_up` | Un control que la historia deja pendiente |

La lista de tipos puede crecer: ignorá los que no conozcas.

| Campo | Descripción |
|-------|-------------|
| `id` | Identificador de la sugerencia |
| `kind` | El tipo, de la lista de arriba |
| `severity` | `red`: revisar antes de cerrar la consulta. `amber`: conviene revisar. `info`: informativa |
| `title` | Título corto |
| `body` | Explicación |
| `question` | Pregunta para el médico, si la sugerencia la tiene |
| `order_id` | El pedido al que se refiere, si se refiere a uno; si no, `null` |
| `proposed_actions` | Acciones que la sugerencia propone (`key`, `label`), para que las muestres. Aplicarlas es decisión del médico en tu sistema |
| `reasons_for` | Solo en `diagnosis_to_consider`: razones a favor, cada una con `text` y `turn_ids` |
| `reasons_against` | Solo en `diagnosis_to_consider`: razones en contra, con la misma forma |
| `sources` | Sus fuentes: `turn_ids` (turnos de la transcripción), `source_ids` (tus registros en `history`) y `guideline` (`title`, `url`, `version` de la guía clínica, o `null`) |
| `ai_generated` | `true` cuando la sugerencia la redacta un modelo de IA (hoy, los diagnósticos para considerar); `false` cuando sale de una regla clínica explícita |

### Códigos CIE-10 (`diagnosis_codes`)

Hasta 4 códigos CIE-10 propuestos para la evaluación, cada uno con `code` (normalizado, ej. "K52.9") y `description`.

### Transcripción (`transcript`)

`transcript.turns` es la conversación, turno por turno:

| Campo | Descripción |
|-------|-------------|
| `id` | Lo que citan los `turn_ids` de la nota, los pedidos y las sugerencias |
| `speaker_id` | Identificador del hablante dentro de la consulta (ej. `speaker_0`) |
| `role` | `doctor`, `patient`, `companion` (quien acompaña al paciente; lo que dice entra en la nota como referido por un familiar) o `unknown` (no se pudo asignar) |
| `segment` | `conversation`, o `dictation` para el dictado posterior del médico |
| `text` | Lo dicho |
| `start`, `end` | Segundos desde el inicio de su audio, o `null` |

### Cómo seguir una cita

- `turn_ids` apunta a `transcript.turns[].id`: con su `role` sabés quién lo dijo y con `start` y `end`, en qué momento.
- `source_ids` apunta a los `id` que vos pusiste en `history`: enlazá el antecedente o la discrepancia a tu propio registro.
- `order_id` apunta a `orders[].id`.

## Errores

Todo error responde con un status no-2xx y este cuerpo:

```json
{
  "detail": {
    "code": "consent_required",
    "message": "Falta el consentimiento del paciente (consent=true)."
  }
}
```

`detail.code` es estable y es lo que tu código debe comparar; `detail.message` es para personas y puede cambiar.

### Credencial y disponibilidad (todos los endpoints)

| Status | `detail.code` | Qué significa | Qué hacer |
|--------|---------------|---------------|-----------|
| 401 | `unauthenticated` | Falta `Authorization: Bearer omp_...` | Mandá la credencial 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 | Pedinos una credencial nueva |
| 503 | `partner_lookup_unavailable` | No pudimos verificar la credencial en este momento. No es un rechazo | Reintentá con espera creciente |
| 503 | `llm_unavailable` | El modelo de IA no está disponible por un momento | Reintentá con espera creciente |

Los `401` traen además el header `WWW-Authenticate`.

### `POST /api/consulta/v1/consultations`

| Status | `detail.code` | Qué significa |
|--------|---------------|---------------|
| 400 | `invalid_audio` | El audio falta, pesa menos de 1 KB, su formato no está soportado o su extensión no coincide con el contenido |
| 403 | `center_not_enabled` | Tu credencial no está habilitada para ese centro |
| 403 | `product_not_enabled` | El centro no tiene Consulta habilitada para tu credencial |
| 409 | `idempotency_key_conflict` | La `Idempotency-Key` ya se usó para otro centro |
| 413 | — | El archivo supera los 100 MB. Esta respuesta puede llegar sin cuerpo JSON: ramificá por el status |
| 422 | `medical_center_id_required` | Falta `medical_center_id` |
| 422 | `consent_required` | Falta `consent=true` |
| 422 | `doctor_id_required` | Falta `doctor_id` |
| 422 | `invalid_doctor` | Un campo del médico supera su largo máximo |
| 422 | `unknown_note_format` | `note_format` no es uno de los de `GET /api/consulta/v1/note-formats` |
| 422 | `invalid_patient` | `patient` no es JSON, no es un objeto o supera 1 MB (un campo con un valor inválido no da error: se descarta) |
| 422 | `invalid_history` | `history` no es JSON, supera 1 MB o un registro no respeta su formato (campos requeridos, fechas, valores permitidos o ids repetidos en una sección) |
| 422 | `invalid_num_speakers` | `num_speakers` no es un entero entre 1 y 10 |
| 422 | `invalid_idempotency_key` | La `Idempotency-Key` supera 255 caracteres |

Los `403` son configuración de nuestro lado: escribinos. Los `400`, `409`, `413` y `422` no se reintentan sin corregir la llamada.

### `GET /api/consulta/v1/consultations/{consultation_id}`

| Status | `detail.code` | Qué significa |
|--------|---------------|---------------|
| 404 | `consultation_not_found` | La consulta no existe, no es de tu credencial o su centro ya no tiene Consulta habilitada para tu credencial |

Una consulta que falló no es un error HTTP: responde `200` con `status: "failed"` y el motivo en `error` (ver [Estados](#estados)).

### `POST /api/consulta/v1/consultations/{consultation_id}/instructions`

| Status | `detail.code` | Qué significa | Qué hacer |
|--------|---------------|---------------|-----------|
| 404 | `consultation_not_found` | Igual que en el `GET` | |
| 409 | `consultation_not_done` | La consulta todavía no terminó de procesarse o falló | Esperá a `done` |
| 422 | `plan_empty` | La nota no tiene Plan del que partir | No hay indicaciones que generar |
| 502 | `generation_failed` | No se pudieron generar | Reintentá |

### `GET /api/consulta/v1/note-formats`

Solo los errores de credencial y disponibilidad.

## Límites

| Límite | Valor |
|--------|-------|
| Audio (`audio`, `dictation`) | Hasta 100 MB por archivo; mínimo 1 KB. Formatos m4a, mp3, wav, webm y ogg |
| Historia clínica (`history`) | Hasta 1 MB de JSON; los textos de más de 8.000 caracteres se recortan |
| Datos del paciente (`patient`) | Hasta 1 MB de JSON |
| `Idempotency-Key` | 255 caracteres |
| `doctor_id`, `doctor_name`, `doctor_specialty` | 255 caracteres |
| `doctor_license` | 100 caracteres |
| `num_speakers` | 1 a 10 |

- Una consulta de 15 minutos en m4a pesa alrededor de 15 MB.
- El tiempo de procesamiento depende de la duración del audio; no hay un tiempo garantizado. Consultá cada 5 segundos durante el primer minuto y después cada 15.
- Reintentá los `5xx` con espera creciente; los `4xx` no se reintentan sin corregir la llamada.

## Datos y seguridad

- Todo el tráfico va cifrado (TLS).
- Nuestros servidores y bases de datos están alojados en la Unión Europea.
- El audio se procesa y no se conserva salvo que el contrato del centro lo pida.
- Qué más se conserva (transcripción, nota, resultado) y por cuánto tiempo se define en el contrato con cada centro.
- La lista de subprocesadores y sus condiciones se entrega bajo acuerdo de confidencialidad.

## Facturación

Cada consulta procesada con éxito cuenta como una consulta. No cuentan:

- reenviar con la misma `Idempotency-Key`;
- una consulta que falla por un problema de nuestro lado;
- generar indicaciones para el paciente.

## Changelog

### v1 (2026-10-30)

- Primera versión: `POST /api/consulta/v1/consultations`, `GET /api/consulta/v1/consultations/{consultation_id}`, `POST .../instructions` y `GET /api/consulta/v1/note-formats`.
- Partner Credential (`omp_...`) habilitada por centro y producto, con el médico declarado en cada consulta.
- Historia clínica opcional con citas a tus propios ids (`source_ids`) y discrepancias.
- Idempotencia con `Idempotency-Key`.

## Soporte

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

- Contrato: [OpenAPI 3.1 de Consulta](https://www.omniai.com.ar/openapi-consulta-v1.json)
- Documentación de la API de radiología: [/api-documentation/radiologia](https://www.omniai.com.ar/api-documentation/radiologia)

---

Source: https://www.omniai.com.ar/api-documentation/consultorio
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
OpenAPI Consultorio v1: https://www.omniai.com.ar/openapi-consulta-v1.json
