# Scribe API

Единая точка распознавания файлов. Клиент отдаёт ссылки — сервис сам определяет,
документ это, аудио или видео, отправляет в нужный контур и возвращает текст
в одном формате.

* **Адрес:** `http://<host>:8080/mcp`
* **Протокол:** MCP поверх Streamable HTTP (один `POST`-эндпоинт, не `/sse`)
* **Формат:** JSON-RPC 2.0
* **Авторизация:** `Authorization: Bearer sk-…`

---

## 1. Аутентификация

Каждый запрос к `/mcp` несёт API-ключ:

```
Authorization: Bearer sk-XXXXXXXXXXXXXXXXXXXXXXXX
```

Ключу можно задать секретное слово и потребовать, чтобы каждый запрос этим ключом
был подписан — см. раздел 6. По умолчанию подпись выключена, и ключ работает как есть.

Ошибки авторизации приходят как JSON-RPC-ошибка с HTTP 401:

| Тело | Когда |
|---|---|
| `Не передан API-ключ: заголовок Authorization: Bearer <ключ>.` | заголовка нет |
| `API-ключ не найден или отозван.` | ключ неизвестен |
| `API-ключ отключён.` | ключ выключен в админке |
| `Срок действия API-ключа истёк.` | истёк `expires_at` |
| `Missing signature headers (X-Signature-Timestamp, X-Signature)` | у ключа включено требование подписи, заголовков нет |
| `Signature timestamp outside the allowed window` | метка старше 5 минут или не число |
| `Invalid request signature` | подпись не сошлась |

---

## 2. Рукопожатие MCP

Стандартная последовательность клиента:

```bash
BASE=http://localhost:8080
KEY=sk-XXXXXXXXXXXXXXXXXXXXXXXX

curl -s -X POST "$BASE/mcp" \
  -H "Authorization: Bearer $KEY" \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{
        "protocolVersion":"2025-06-18","capabilities":{},
        "clientInfo":{"name":"my-agent","version":"1.0"}}}'
```

Ответ содержит согласованную версию протокола и описание сервера; в заголовке
`Mcp-Session-Id` приходит идентификатор сессии.

```json
{"jsonrpc":"2.0","id":1,"result":{
  "protocolVersion":"2025-06-18",
  "capabilities":{"tools":{"listChanged":false},"logging":{}},
  "serverInfo":{"name":"scribe","title":"Scribe","version":"1.0.0"},
  "instructions":"Scribe — единая точка распознавания…"}}
```

Дальше клиент шлёт уведомление (ответа не будет, вернётся `202`):

```json
{"jsonrpc":"2.0","method":"notifications/initialized"}
```

Поддерживаются также `tools/list` и `ping`. Батч JSON-RPC (массив сообщений в одном
теле) тоже принимается.

---

## 3. Инструменты

### 3.1 `recognize_files`

Распознаёт документы, аудио и видео по публичным URL.

**Вход**

```json
{
  "type": "object",
  "additionalProperties": false,
  "required": ["urls"],
  "properties": {
    "urls": {
      "type": "array", "minItems": 1, "maxItems": 20, "uniqueItems": true,
      "items": { "type": "string", "pattern": "^https?://" }
    }
  }
}
```

Указывать тип файла не нужно — сервер определяет его сам по расширению, а если
его нет, то по `Content-Type` и `Content-Disposition` источника. Документы и
аудио можно смешивать в одном вызове: они уходят в разные контуры параллельно.

**Выход** — всегда объект; массив результатов лежит внутри.

```json
{
  "type": "object",
  "additionalProperties": false,
  "required": ["status"],
  "properties": {
    "status": { "type": "string", "enum": ["ok", "pending", "error"] },
    "items":  { "type": "array", "items": {
        "type": "object",
        "required": ["index", "kind", "text"],
        "properties": {
          "index":         { "type": "integer" },
          "kind":          { "type": "string", "enum": ["document", "audio"] },
          "text":          { "type": "string" },
          "language":      { "type": "string" },
          "duration":      { "type": "number" },
          "segments":      { "type": "array" },
          "processing_ms": { "type": "integer" }
        }}},
    "job_id":        { "type": "string" },
    "error_message": { "type": "string" }
  }
}
```

Результат приходит и текстом (в `content[0].text`), и структурой
(в `structuredContent`) — вторым пользоваться удобнее.

### 3.2 `poll_status`

Забирает результат асинхронной задачи.

```json
{ "type":"object", "required":["job_id"],
  "properties": { "job_id": { "type":"string", "minLength":1 } } }
```

Выход тот же, что у `recognize_files`.

---

## 4. Поведение

**Гибрид sync/async.** Сервер ждёт результат до **50 секунд**. Успел — вернёт `ok`
с готовыми `items`. Не успел — вернёт `pending` и `job_id`. Отсчёт идёт с момента
приёма сообщения, скачивание и очередь входят в этот бюджет. Ответ всегда уходит
раньше клиентского таймаута (~60 с); сервер не зависает на вызове.

**Умный `poll_status`.** Если результат ещё не готов, сервер держит соединение
до **30 секунд** и отдаёт результат сразу, как только он появится. Для клиента это
выглядит как обычная задержка ответа, но опрашивать каждые пять секунд не нужно.

**Пачка атомарна.** Либо готова вся пачка (`ok` с полным `items`), либо вся ушла
в async (`pending`), либо вся упала (`error`). Своего статуса у отдельного файла нет.

**`pending` — не ошибка.** Флаг `isError` для него не выставляется. Не повторяйте
вызов вслепую: дождитесь через `poll_status`.

**Пустой текст — не ошибка.** Если распознавать было нечего, придёт `ok` с `text: ""`.

**Идемпотентность.** Уже обработанный URL берётся из кеша и отдаётся мгновенно.
Дубли внутри одной пачки отбрасываются: два одинаковых URL дадут один `item`.

**Что под капотом (клиенту не видно).** Документы: сохранение структуры и
распознавание вложенных изображений, выход — markdown. Аудио и видео: диаризация
включена, число собеседников — авто, язык `ru`, реплики склеены в строки
`HH:MM:SS. Спикер N: текст`. Из видео звук извлекается сервером.

---

## 5. Обратные вызовы

Если у ключа задан `callback_url`, готовая пачка уходит туда `POST`-запросом
с тем же телом, что вернул бы инструмент:

```
POST /your/hook HTTP/1.1
Content-Type: application/json; charset=utf-8
User-Agent: Scribe callback
X-Scribe-Job-Id: 51fb0e99-b9ad-4277-9331-fddcf4abc579
X-Webhook-Timestamp: 1788952750
X-Webhook-Signature: 4Rk7…=
```

Два последних заголовка появляются, только если у ключа задано секретное слово
**и** включён `sign_callbacks`. Доставка повторяется с нарастающей паузой;
метка времени и подпись пересчитываются на каждую попытку, поэтому проверять
надо ту пару заголовков, что пришла с текущим запросом. Приёмник должен быть
готов к повтору уже доставленного вызова — дубль опознаётся по `X-Scribe-Job-Id`.

---

## 6. Подпись HMAC-SHA256

Схема совпадает с контурами ASR и OCR байт в байт: HMAC-SHA256, ключ — секретное
слово в UTF-8, результат — Base64 от сырых байт дайджеста (не от hex-строки).

| Заголовок | Направление | Подписываемая строка |
|---|---|---|
| `X-Signature-Timestamp` / `X-Signature` | клиент → Scribe | `<ts>.<МЕТОД>.<путь со строкой запроса>` |
| `X-Webhook-Timestamp` / `X-Webhook-Signature` | Scribe → приёмник | `<job_id>.<ts>` |

`<ts>` — Unix-время в секундах, ровно то же число, что уходит в заголовке.
Путь подписывается «как есть», байт в байт с тем, что реально уходит на сервер.
Для `/mcp` строка запроса пуста, поэтому подписывается `<ts>.POST./mcp`.

Окно свежести — **300 секунд** в обе стороны. Если часы разъезжаются, лечится
это синхронизацией времени, а не расширением окна.

**bash + openssl**

```bash
TS=$(date +%s)
SIG=$(printf '%s' "$TS.POST./mcp" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)

curl -s -X POST "$BASE/mcp" \
  -H "Authorization: Bearer $KEY" \
  -H "X-Signature-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

`printf` здесь важен: `echo` добавил бы перевод строки в подписываемые данные.

**Python**

```python
import base64, hashlib, hmac, time

def signed_headers(method: str, path: str, secret: str) -> dict:
    ts = str(int(time.time()))
    digest = hmac.new(secret.encode(), f"{ts}.{method.upper()}.{path}".encode(),
                      hashlib.sha256).digest()
    return {"X-Signature-Timestamp": ts,
            "X-Signature": base64.b64encode(digest).decode()}
```

**Проверка подписи входящего callback**

```python
def verify(job_id: str, ts: str, signature: str, secret: str) -> bool:
    if abs(time.time() - int(ts)) > 300:
        return False
    want = base64.b64encode(hmac.new(secret.encode(), f"{job_id}.{ts}".encode(),
                                     hashlib.sha256).digest()).decode()
    return hmac.compare_digest(want, signature)
```

---

## 7. Полный пример

```python
import json, time, urllib.request

BASE = "http://localhost:8080"
KEY  = "sk-XXXXXXXXXXXXXXXXXXXXXXXX"

def call(name, arguments):
    payload = {"jsonrpc": "2.0", "id": 1, "method": "tools/call",
               "params": {"name": name, "arguments": arguments}}
    req = urllib.request.Request(
        BASE + "/mcp", data=json.dumps(payload).encode(), method="POST",
        headers={"Content-Type": "application/json",
                 "Authorization": "Bearer " + KEY,
                 "Accept": "application/json, text/event-stream"})
    with urllib.request.urlopen(req, timeout=120) as resp:
        return json.loads(resp.read())["result"]["structuredContent"]

state = call("recognize_files", {"urls": [
    "https://example.com/act.pdf",
    "https://example.com/call.mp3",
]})

while state["status"] == "pending":
    state = call("poll_status", {"job_id": state["job_id"]})   # держит до 30 с

if state["status"] == "error":
    raise RuntimeError(state["error_message"])

for item in state["items"]:
    print(item["index"], item["kind"], item["text"][:200])
```

---

## 8. Лимиты

| Параметр | Значение |
|---|---|
| Файлов за вызов | 1…20, дубли отбрасываются |
| Размер аудио и видео | до 8 ГБ |
| Размер документа | до 200 МБ |
| Окно синхронной фазы | 50 с |
| Ожидание в `poll_status` | до 30 с |
| Жизнь `job_id` | 24 ч |
| Кеш результата по URL | 24 ч |
| Окно свежести подписи | 300 с |

Все значения меняются в админке без правки кода.

Принимаются только публичные `http`/`https`-адреса. Приватные, loopback- и
link-local-диапазоны отклоняются: имя резолвится до отправки, и проверяется
каждый полученный адрес.

**Поддерживаемые типы.** Документы: PDF, PNG, JPEG, TIFF, BMP, GIF, WEBP, DOCX,
XLSX, PPTX, ODT, ODS, RTF. Аудио и видео: MP3, M4A, WAV, WEBM, OGG, FLAC, AAC,
OPUS, MP4, MOV, MKV, AVI, MXF.

---

## 9. Ошибки

Ошибки инструментов возвращаются с HTTP 200: `isError: true` и
`{"status":"error","error_message":"…"}` внутри. Текст всегда объясняет причину
и что поправить.

| Ситуация | Сообщение |
|---|---|
| Не публичная ссылка | `Ссылка ведёт во внутреннюю сеть и отклонена: … Принимаются только публичные адреса…` |
| Ссылка не открывается | `Ссылка недоступна (…): … Проверьте, что файл лежит по этому адресу…` |
| Неподдерживаемый тип | `Тип файла не поддерживается (расширение «.zip»): … Документы: PDF… Аудио и видео: MP3…` |
| Больше 20 файлов | `Максимум 20 файлов за вызов, передано 25. Разбейте список на несколько вызовов.` |
| Файл слишком большой | `Файл больше допустимого: … при лимите … МБ.` |
| Источник слишком медленный | `Источник не отдал файл за отведённое время: … Ссылка слишком медленная…` |
| Контур недоступен | `Сервис распознавания … недоступен, попробуйте позже.` |
| Контур не настроен | `Контур OCR не настроен: в админке Scribe не задан адрес или API-ключ.` |
| Задача не найдена | `Задача не найдена: … Убедитесь, что job_id получен из recognize_files…` |
| Задача устарела | `Задача устарела, пересоздайте recognize_files: …` |

Ошибки транспорта (нет ключа, плохая подпись, битый JSON) приходят как
JSON-RPC-ошибка с соответствующим HTTP-кодом: 401, 400, 413.

---

## 10. Служебное

| Метод | Назначение |
|---|---|
| `GET /health` | проверка живости, без авторизации |
| `GET /admin` | админ-панель |
| `GET /docs` | эта инструкция как страница |
| `GET /docs/api.md` | её исходник в markdown |
| `POST /hooks/ocr`, `POST /hooks/stt` | приём callback от контуров (включается в админке) |
