Omnimed AI

    Guía de integración de Omnimed AI

    Integrá el dictado de informes de Omnimed AI en tu RIS, HIS o visor.

    1. Widget

    Agregá un tag <script> v2 a tu página y pasale getToken: tu backend cambia la Partner Credential por un token de 1 h por médico. El informe puede autoinsertarse en el editor activo.

    Usá esta opción si: querés la integración más rápida y te sirve la interfaz de grabación por defecto.

    2. API directa

    Enviá un archivo de audio por POST a /api/omni-ai desde tu servidor, con tu Partner Credential como Bearer, y manejá la respuesta vos mismo.

    Usá esta opción si: necesitás control total de la interfaz de grabación o integrás desde un cliente nativo o de escritorio.

    Inicio rápido

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

    1. Pedí tu Partner Credential (credencial de partner) a [email protected]. Es un secreto omp_... que se muestra una sola vez: guardalo como variable de entorno de tu servidor. Ver Alta de un partner.

    2. Hacé la primera llamada. Desde tu servidor, con un audio de prueba:

      curl -X POST https://api.omniai.com.ar/api/omni-ai \
        -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
        -F "[email protected]" \
        -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).

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

    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

    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:

    usernamePlantillas entre las que se elige
    ausente (solo API directa)Solo las plantillas generales del centro
    un usuario del centroLas generales más las asignadas a ese médico (directamente o por grupo)
    un usuario que el centro no conoceSolo 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óndeCampoRequeridoDescripción
    HeaderAuthorization: Bearer omp_...SíTu Partner Credential. Solo en tu servidor
    Body JSONmedical_center_idSíEl centro para el que actúa el Widget (el mismo ID que en v1). Tiene que estar habilitado para tu credencial
    Body JSONusernameSíEl médico que usa el Widget, con su usuario en el centro (médico declarado). Un token es por médico
    Body JSONuser_idNoTu propio ID del médico; solo se registra

    Respuesta 200:

    {
      "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

    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+).

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

    <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 initDescripción
    getTokenFunción que devuelve (o promete) el token omw_... como texto. Recomendada siempre
    tokenToken inicial, opcional
    apiUrlURL base de la API. Por defecto, el origen desde el que se cargó el script (https://api.omniai.com.ar)
    onReportCallback con el informe, igual que el evento omniai:report
    onAudioCallback con la grabación cruda, igual que el evento omniai:audio
    onAddendumCallback con la adenda, igual que el evento omniai:addendum
    addendumButtonfalse 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:

    // 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:

    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
    });
    CampoDescripción
    blobLa grabación (Blob), normalmente WebM/Opus (Ogg o MP4 en navegadores sin WebM)
    mimeTypeTipo MIME del blob
    durationSecondsDuración de la grabación
    sizeBytesTamaño del blob
    kind"dictation", "refinement" (una corrección por voz) o "addendum"
    recordingIdID único de esta grabación. Los payloads de omniai:report y omniai:addendum traen el mismo recordingId, que vincula audio y resultado
    parentRecordingIdEn un refinamiento, el recordingId del dictado que corrige; null en los demás
    recordedAtTimestamp 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.

    <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).
    • 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:addendumDescripción
    htmlLa adenda en HTML (párrafos, sin título)
    textLa misma adenda en texto plano
    transcriptionEl dictado transcripto
    externalStudyIdEl que pasaste a openAddendum, o null
    recordingIdEl mismo del omniai:audio de esa grabación (que llega con kind: "addendum")
    eventIdID 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

    EstadoSignificado
    idleListo para grabar
    recordingMicrófono activo
    pausedGrabación en pausa
    processingAudio subido, generando informe
    resultInforme visible en el panel
    errorFalló 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ámetroTipoRequeridoDescripción
    fileFileCondicionalArchivo de audio (wav, mp3, m4a, ogg, flac, webm), hasta 100 MB. Requerido salvo que se envíe transcription_text
    transcription_textStringCondicionalTexto del dictado usado en lugar del audio (ver abajo). Requerido salvo que se envíe file
    medical_center_idStringSíCentro habilitado para tu credencial. Con un token del Widget no hace falta y se ignora
    usernameStringRecomendadoMé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_idStringNoIdentificador del médico/usuario para analítica
    languageStringNoNo 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
    metadataString (JSON)NoMetadata JSON libre, máx 10KB
    dicom_metadataString (JSON)NoEstudio pedido, de la orden / headers DICOM; dispara los avisos de discrepancia (ver abajo)
    template_htmlString (HTML)NoPlantilla preseleccionada: generar el informe contra exactamente esta plantilla (ver abajo)
    template_nameStringNoEtiqueta de la plantilla preseleccionada; se devuelve como study_name / template_used
    include_templateBooleanNoDevolver el HTML de la plantilla que usó el servidor, para resaltar diferencias
    include_rtfBooleanNoDevolver 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:

    {
      "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.

    {
      "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:

    {
      "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

    curl -X POST https://api.omniai.com.ar/api/omni-ai \
      -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
      -F "[email protected]" \
      -F "medical_center_id=YOUR_CENTER_ID" \
      -F "username=dr.martinez"

    cURL (plantilla preseleccionada)

    curl -X POST https://api.omniai.com.ar/api/omni-ai \
      -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
      -F "[email protected]" \
      -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

    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)

    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ámetroTipoRequeridoDescripción
    fileFileCondicionalAudio con la corrección dictada. Requerido salvo que se envíe transcription_text
    transcription_textStringCondicionalLa corrección como texto en lugar de audio (máx 50KB, mismas reglas que en generación: el audio gana, blanco = ausente)
    medical_center_idStringSíCentro habilitado para tu credencial. Con un token del Widget no hace falta y se ignora
    report_htmlString (HTML)SíEl último informe generado, exactamente como lo devolvió la API (o como quedó tras el último refinamiento)
    template_htmlString (HTML)NoLa plantilla original del informe; se usa como referencia de estilo/formato de solo lectura (máx 100KB)
    template_nameStringNoNombre de la plantilla/tipo de estudio, habilita reglas por tipo de estudio
    selected_htmlString (HTML)NoElemento HTML exacto a refinar; omitilo para que el modelo ubique el objetivo desde el dictado
    study_idIntegerNoEl metadata.study_id de la llamada de generación, para trazar el refinamiento
    user_idStringNoIdentificador del médico/usuario para analítica
    languageStringNoSe acepta por compatibilidad y se ignora: se usa el idioma del centro

    Respuesta exitosa

    {
      "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

    curl -X POST https://api.omniai.com.ar/api/omni-ai/refine \
      -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
      -F "[email protected]" \
      -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 (diff en npm), sin soporte del servidor:

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

    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ámetroTipoRequeridoDescripción
    fileFileCondicionalAudio con la adenda dictada. Requerido salvo que se envíe transcription_text
    transcription_textStringCondicionalLa adenda como texto en lugar de audio (máx 50KB, mismas reglas que en generación: el audio gana, blanco = ausente)
    medical_center_idStringSíCentro habilitado para tu credencial. Con un token del Widget no hace falta y se ignora
    usernameStringRecomendadoMédico declarado; queda registrado en la adenda
    signed_reportString (HTML o texto)NoEl informe firmado tal como lo tiene tu sistema, ediciones manuales incluidas (máx 100KB)
    external_study_idStringNoTu propio identificador del estudio, guardado tal cual, para ubicar la adenda en soporte
    user_idStringNoIdentificador del médico/usuario para analítica
    languageStringNoSe acepta por compatibilidad y se ignora: se usa el idioma del centro

    Respuesta exitosa

    {
      "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

    curl -X POST https://api.omniai.com.ar/api/omni-ai/addendum \
      -H "Authorization: Bearer $OMNI_PARTNER_CREDENTIAL" \
      -F "[email protected]" \
      -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:

    {
      "detail": {
        "code": "center_not_enabled",
        "message": "La Partner Credential no está habilitada para el centro 'your-center'."
      }
    }
    Statusdetail.codeQué significaQué hacer
    401missing_partner_credentialPOST /api/widget/token sin Authorization: Bearer omp_...Mandá la credencial como Bearer desde tu servidor
    401invalid_partner_credentialCredencial desconocida (mal copiada o rotada)Revisá el valor; si se rotó, usá la nueva
    401partner_credential_revokedCredencial revocada. Los tokens del Widget que emitió dejan de valer en menos de 60 segundosPedinos una credencial nueva
    401widget_token_expiredToken del Widget vencidoEl Widget lo renueva solo si tiene getToken
    401invalid_widget_tokenToken del Widget alterado o de otro servidorPedí un token nuevo con POST /api/widget/token
    403center_not_enabledTu credencial no está habilitada para ese medical_center_idEs configuración de nuestro lado: escribinos
    503widget_token_not_configuredProblema de nuestro lado al emitir o validar tokensReintentá con backoff
    503partner_lookup_unavailableNo pudimos verificar la credencial en este momento. No es un rechazoReintentá con backoff

    Errores generales

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

    {
      "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.

    StatusCausaQué hacer
    400Falta 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 refineCorregí la llamada; no tiene sentido reintentarla igual
    403Centro deshabilitadoContactanos
    422El centro no tiene plantillas cargadas (error_code 1011)Escribinos para cargar las plantillas del centro
    500Falló 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
    502El modelo devolvió una adenda vacíaVolvé a dictar la adenda
    503En 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:

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

    Límites y operación

    LímiteValor
    Audio (file)Hasta 100 MB; mínimo 1KB
    Grabación del Widget10 minutos
    transcription_text50KB
    metadata10KB
    template_html, signed_report100KB
    Vida del token del Widget1 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:

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

    Respuesta:

    {
      "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 [email protected].
    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: [email protected]

    Migrar de v1 a v2

    v1v2
    Autenticación de la API directaNinguna: solo medical_center_idAuthorization: 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 Widgetdata-center-id / data-username en la páginaDentro del token, emitido por tu backend
    Autenticación del WidgetNingunaAuthorization: Bearer omw_... en cada llamada, renovado con getToken()
    AdendasNoPOST /api/omni-ai/addendum y OmniWidget.openAddendum
    Errores de credencialNo existen{"detail": {"code", "message"}}
    Parámetros, respuestas e informesIguales

    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 [email protected] 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.

    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

    Soporte

    Consultas, Partner Credentials y alta de centros: [email protected]