# Omni-AI Integration Guide

> **v1**: authentication by `medical_center_id` only. For new integrations use [v2](https://www.omniai.com.ar/en/api-documentation/v2), with a Partner Credential and a token-based Widget.

## Overview

Omni-AI turns a medical dictation (audio) into a structured, template-filled report in a single request. You can integrate in two ways:

1. Embeddable Widget — drop a `<script>` tag into your page. The doctor records from a floating button, the report is returned and can be auto-inserted into your editor.
2. Direct API — call `POST https://api.omniai.com.ar/api/omni-ai` with the audio file yourself and handle the response.

Both routes use the same omni-ai endpoint under the hood. Start with the Widget unless you need full control of the recording UI.

## Authentication

Each integration partner receives a `medical_center_id` (provisioned by Omni). The center ID identifies the templates, language and report style used to generate the output. No bearer token is required today.

Contact omni@omniai.com.ar to provision a center ID.

## Widget

### Embed

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

A floating recording button is injected in the bottom-right. The doctor clicks it, dictates, stops, and the widget shows the generated report.

`data-username` is optional. It is the doctor's username in this center (the same login they use in the desktop app) and it decides which templates the dictation is matched against:

| `data-username` | Templates matched |
|-----------------|-------------------|
| absent | Only the center's general templates |
| a username of the center | The general templates plus the ones assigned to that doctor (directly or through a group) |
| a username the center does not know | Only the general templates |

### Auto-insert the report

The widget emits an `omniai:report` event when the report is ready, and again each time the doctor clicks "Insertar". `event.detail` carries `html`, `text` and the dictation's `recordingId`; if you auto-insert, replace what you already inserted for that `recordingId` instead of appending a second copy. Listen for it and paste the HTML (or plain text) into your editor:

```javascript
window.addEventListener('omniai:report', (event) => {
  const { html, text } = event.detail;
  // e.g. paste into the focused contenteditable / rich-text field
  document.execCommand('insertHTML', false, html);
});
```

Or initialize manually with a callback:

```javascript
OmniWidget.init({
  centerId: 'YOUR_CENTER_ID',
  username: 'DOCTOR_USERNAME', // optional, same rules as data-username
  onReport: ({ html, text, recordingId }) => {
    // Insert into your report editor
  },
  onAudio: (audio) => {
    // Optional: keep your own copy of the recording (see below)
  }
});
```

### Keep a copy of the audio

If your system must retain the original dictation (e.g. PACS legal retention), the widget hands you the raw recording the moment the doctor stops, **before** uploading it, so your copy exists even if processing fails. Listen for `omniai:audio` or pass `onAudio` to `init`:

```javascript
window.addEventListener('omniai:audio', (event) => {
  const { blob, mimeType, kind, recordingId, parentRecordingId } = event.detail;
  // e.g. upload blob to your storage, keyed by your study ID + recordingId
});
```

| Field | Description |
|-------|-------------|
| blob | The recording (`Blob`), usually WebM/Opus (Ogg or MP4 on browsers without WebM) |
| mimeType | MIME type of the blob |
| durationSeconds | Recording length |
| sizeBytes | Blob size |
| kind | `"dictation"` or `"refinement"` (a spoken correction) |
| recordingId | Unique ID of this recording. The `omniai:report` payload carries the dictation's `recordingId`, linking audio and report |
| parentRecordingId | For a refinement, the `recordingId` of the dictation it corrects; `null` for dictations |
| recordedAt | ISO 8601 timestamp |

The blob never leaves your page through this event: storing it is up to your code.

### Widget lifecycle

| State | Meaning |
|-------|---------|
| idle | Ready to record |
| recording | Microphone active |
| paused | Recording paused |
| processing | Audio uploaded, report generating |
| result | Report shown in panel |
| error | Processing failed; the doctor can record again |

### Browser requirements

- HTTPS (required for microphone access)
- Permission granted for `navigator.mediaDevices.getUserMedia`
- Max recording duration: 10 minutes (configurable)

---

## Direct API

### Generate a report

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

#### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| file | File | Conditional | Audio file (wav, mp3, m4a, ogg, flac, webm). Required unless `transcription_text` is sent |
| transcription_text | String | Conditional | Dictation text used instead of audio (see below). Required unless `file` is sent |
| medical_center_id | String | Yes | Center identifier provided by Omni |
| user_id | String | No | Doctor/user identifier for analytics |
| username | String | No | Doctor's username in the center. Matching uses that doctor's visible templates (general + assigned). Absent or unknown: general templates only. Ignored when `template_html` is sent |
| language | String | No | Accepted for compatibility and ignored: dictation and report use the language of the center (`medical_center_id`) |
| metadata | String (JSON) | No | Free-form JSON metadata, max 10KB |
| dicom_metadata | String (JSON) | No | Ordered study from the order / DICOM headers; drives the mismatch warnings (see below) |
| template_html | String (HTML) | No | Preselected template: generate the report against exactly this template (see below) |
| template_name | String | No | Label for the preselected template; echoed back as `study_name` / `template_used` |
| include_template | Boolean | No | Return the template HTML the server used, for diff highlighting |
| include_rtf | Boolean | No | Return RTF version of each report |

> **Note**: Without `template_html`, study detection is automatic from the transcription and the template is matched against the center's stored templates — you do not need to send the study name.

> **Several studies in one dictation**: `reports` always has one entry. When the doctor dictates several studies, their reports come concatenated in that single `report_html`, `study_name` / `template_used` list them comma-separated, and `metadata.is_multi_study` is `true`.

#### Sending text instead of audio (`transcription_text`)

If your system already has the dictation as text (typed, or transcribed on your side), send it as `transcription_text` and omit the audio — the transcription step is skipped and the text enters the pipeline exactly where the transcription would: study detection, template matching and generation behave identically. It combines naturally with `template_html` (no transcription *and* no matching — the fastest, fully deterministic path).

- **Audio always wins.** If you send both, the audio is transcribed and `transcription_text` is ignored entirely. There is no fallback: if the audio fails to transcribe (e.g. muted microphone) the call errors even when text was sent.
- A blank `file` field or a blank `transcription_text` ("" or whitespace) is treated as absent. Sending neither returns a 400.
- Plain text, max **50KB**.
- In the response: `duration_seconds` is `0.0`, `speaker_count` is `1`, `language_detected` echoes your `language` parameter, and `metadata.input_source` is `"text"` (vs `"audio"`).
- Billing is unchanged — a report generated from text consumes credit exactly like one generated from audio.

#### Sending your own template (`template_html`)

When your system knows which template the report must follow (per clinic, per referring center, per modality), send it in the call:

- Study detection and template matching are **skipped entirely** — the report deterministically follows your template.
- The result is always a **single report**, even if the dictation mentions several studies.
- The HTML is sanitised server-side before use; max size is **100KB**. Oversized HTML, or markup that is empty after sanitisation, returns a 400. A blank field ("" or whitespace) is treated as absent: the call falls back to the normal matching path.
- HTML is the recommended format: it preserves bold, headings and block structure.
- In the response: `template_match_confidence` is `1.0` and `metadata.detection_source` is `"preselected_template"`.
- With `include_template=true`, `template_html` in the response is the sanitised template the server actually used — diff against this, not your original.

#### Success response

```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**: Keep `data.metadata.study_id` if you plan to refine this report later.

#### Mismatch warnings (laterality, modality, region)

Send the ordered study in `dicom_metadata` and the API compares it with what the doctor dictated. A discrepancy comes back in `data.warnings`; the report is generated exactly the same and is never changed or blocked by it. Neither side is assumed correct: the order can be wrong too.

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

- `dicom_modality` (required when `dicom_metadata` is sent): DICOM Modality (`CT`, `MR`, `DX`/`CR`, `US`, `MG`).
- `dicom_bodypart`: DICOM Body Part Examined, one value (`HAND`, `KNEE`, `SHOULDER`, `FOOT`, `BREAST`, ...). Region is checked for extremities and breast; other body parts are not checked yet.
- `dicom_laterality`: `R`, `L` or `B` (`derecha` / `izquierda` / `bilateral` also accepted). If absent, the side is read from `dicom_series_description` when it says it in words (`DER`, `IZQ`, `DERECHA`, `LEFT`, ...).
- Free-form context such as the clinical indication goes in `metadata`, which is context for the report, not input to these checks.

Each warning:

```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` or `REGION_MISMATCH`.
- `context_value` / `dictated_value`: laterality `R`/`L`; modality `RX`, `TC`, `RM`, `ECO`, `MAMO`; region a Spanish key such as `mano`, `pie`, `rodilla`, `muneca`.
- `evidence`: literal fragment of the dictation, when available. `message`: Spanish, ready to show the radiologist.

`context_checks` says, per check, whether it ran: `ok`, `mismatch`, or `skipped` with a `reason` (`no_context`: not sent; `unmapped`: a value we do not check yet; `not_dictated`: the doctor did not say it; `bilateral_context`: the order is bilateral; `llm_unavailable`: the check could not finish in time). An empty `warnings` with `skipped` checks means "not verified", not "verified OK".

The checks are tuned to avoid false alarms: when in doubt they do not warn. Mentions of the other side for comparison ("comparado con la rodilla izquierda") or of another modality as a suggestion ("se sugiere RM") are not mismatches.

#### Examples

**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 (preselected template)**

```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;
}
```

### Refine a report

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

The doctor dictates a correction to an already-generated report ("cambiar menisco interno por menisco externo"). The server transcribes the audio, changes **only** the targeted element — plus the report's Conclusion when the change affects it — and returns the updated report. Refinement never regenerates the whole report, and it is not billed.

#### Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| file | File | Conditional | Audio with the dictated correction. Required unless `transcription_text` is sent |
| transcription_text | String | Conditional | The correction as text instead of audio (max 50KB, same rules as on generation: audio wins, blank = absent) |
| medical_center_id | String | Yes | Center identifier provided by Omni |
| report_html | String (HTML) | Yes | The last generated report, exactly as returned by the API (or as last refined) |
| template_html | String (HTML) | No | The original template of the report; used as a read-only style/format reference (max 100KB) |
| template_name | String | No | Template/study-type name, enables per-study-type rules |
| selected_html | String (HTML) | No | Exact HTML element to refine; omit to let the model locate the target from the dictation |
| study_id | Integer | No | `metadata.study_id` from the generation call, to track the refinement |
| user_id | String | No | Doctor/user identifier for analytics |
| language | String | No | Accepted for compatibility and ignored: the language of the center is used |

#### Success response

```json
{
  "success": true,
  "message": "Successfully refined report",
  "data": {
    "transcription": "cambiar menisco interno por menisco externo",
    "report_html": "<p>...full report with the correction applied...</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` is the full report with the correction already applied — use it directly.
- `data.patch` describes the exact regions that changed, if you prefer to splice them into your own editor: replace the first occurrence of `target_html` with `refined_text`, and of `conclusion_original_text` with `conclusion_text` (when non-null).
- To chain refinements, send the returned `report_html` as the input of the next refine call.

#### Example

```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>...last generated report...</p>" \
  -F "template_html=<p>...original template...</p>" \
  -F "study_id=12345"
```

### Error response

Errors return a non-2xx status with:

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

`error_code` is usually the HTTP status. Some errors carry a more specific code instead (e.g. `1011`: the center has no templates; `1006`: the report model is unavailable).

| Status | Cause | Fix |
|--------|-------|-----|
| 400 | Missing input (neither file nor transcription_text), unsupported audio format, malformed metadata JSON, or oversized template_html | Send wav/mp3/m4a/ogg/flac/webm or transcription_text (max 50KB); validate JSON under 10KB; keep template_html under 100KB |
| 403 | Center disabled / not provisioned | Contact Omni to enable your `medical_center_id` |
| 422 | The center has no templates configured (`error_code` 1011) | Contact Omni to load your center's templates |
| 500 | Transcription or report generation failed (e.g. silent audio / muted microphone) | Check the recording; retry with backoff; contact support if persistent |
| 503 | Report generation model temporarily unavailable (`error_code` 1006) | Retry with backoff |

---

## Highlighting AI changes (client-side diff)

The Widget highlights what the AI changed by diffing the template against the generated report. You can do the same in your UI with [jsdiff](https://github.com/kpdecker/jsdiff) (`diff` on npm) — no server support needed:

```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('');
}
```

- Baseline: request `include_template=true` on generation and diff against the returned `template_html`.
- After a refine, diff the previous `report_html` against the new one to flash only the correction.
- Diff on visible text (e.g. from `DOMParser`) rather than raw HTML strings for cleaner highlights.

## Health check

Use this endpoint to verify connectivity from your backend:

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

Response:

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

## Support

Questions and provisioning: omni@omniai.com.ar

---

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