# ГосЗадание API

ИИ-аудит новостей на соответствие государственному заданию Московской области.
Клиент передаёт статьи пачкой — сервис проверяет каждую по трём шагам ТЗ и
возвращает вердикт с причиной, обоснованием, ключевыми значениями и синонимами.

* **Адрес:** `https://<host>/api/v1`
* **Формат:** JSON, UTF-8
* **Авторизация:** `Authorization: Bearer sk-…`
* **Ошибки:** всегда `{"error": "<что случилось и что сделать>"}`

---

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

Ключ выдаёт администратор в админке, вкладка «Ключи». У ключа есть права:

| Право | Что открывает |
|---|---|
| `checks` | пачки, статьи, результаты, синхронная проверка |
| `keywords:read` | чтение справочника |
| `keywords:write` | полный снимок справочника, поле `keywords` при открытии пачки |

Ошибки авторизации:

| Код | Тело | Когда |
|---|---|---|
| 401 | `Не передан API-ключ: заголовок Authorization: Bearer <ключ>.` | заголовка нет |
| 401 | `API-ключ не найден или отозван.` | ключ неизвестен |
| 401 | `API-ключ отключён.` | ключ выключен в админке |
| 401 | `Срок действия API-ключа истёк.` | истёк срок |
| 403 | `У ключа нет права «…». …` | не хватает права |
| 429 | `Превышен лимит запросов ключа. Повторите через N с.` | частота выше лимита ключа; заголовок `Retry-After` |

---

## 2. Месячная пачка

Порядок работы Терминала:

1. Открыть пачку за период.
2. Передать статьи частями по 500.
3. Закрыть пачку.
4. Дождаться callback `batch.done` или опрашивать прогресс.
5. Забрать результаты постранично.

### 2.1 Открыть пачку

    POST /api/v1/batches

```json
{"period": "2026-09", "external_ref": "сентябрь-2026", "callback_url": null, "keywords": null}
```

| Поле | Что |
|---|---|
| `period` | **обязательно**, `ГГГГ-ММ`. По году выбирается справочник ключевых значений |
| `external_ref` | ваша метка пачки, до 200 символов |
| `callback_url` | адрес уведомления; принимается, только если его хост разрешён ключу администратором |
| `keywords` | необязательно: полный список справочника Терминала (нужно право `keywords:write`), см. §5 |

Ответ `201`:

```json
{"batch_id": "9f2c…", "status": "open", "period": "2026-09", "year": 2026, "dict_version": 14,
 "counts": {"queued": 0, "running": 0, "done": 0, "error": 0, "skipped": 0, "total": 0, "needs_review": 0}}
```

**Справочник фиксируется при открытии.** Все статьи пачки проверяются по версии
`dict_version`. Изменения справочника во время обработки действуют со следующей
пачки — месячный отчёт не получит вердикты по разным справочникам.

### 2.2 Передать статьи

    POST /api/v1/batches/{batch_id}/articles

```json
{"articles": [
  {"id": "123456", "title": "Заголовок", "text": "Полный текст статьи…",
   "url": "https://riamo.ru/news/…", "published_at": "2026-09-14T10:42:00+03:00", "is_gov_contract": true}
]}
```

| Поле | Что |
|---|---|
| `id` | id статьи в Терминале, строка до 200 символов, **обязательно** |
| `title`, `text` | заголовок и полный текст; текст до 100 000 символов |
| `url`, `published_at` | необязательно |
| `is_gov_contract` | пометка ГЗ. Не передана — статья считается помеченной; `false` — статья принимается, но не анализируется (`state: "skipped"`) |

Ответ:

```json
{"accepted": 498, "existing": 0, "skipped": 2, "duplicates": 11, "batch": {"…": "…"}}
```

**Идемпотентность — внутри пачки.** Повтор той же статьи (тот же `id` и текст)
ничего не создаёт и считается в `existing` — запрос можно смело повторить после
обрыва связи. Тот же `id` с другим текстом в той же пачке — `409`: откройте
новую пачку.

**Дубли.** Одинаковый текст под разными `id` (зеркала на городских порталах)
проверяется один раз; у копий в результате `duplicate_of` — `id` первой копии.

### 2.3 Закрыть пачку и следить за прогрессом

    POST /api/v1/batches/{batch_id}/close
    GET  /api/v1/batches/{batch_id}

Пачка становится `done`, когда закрыта и в ней нет статей в очереди или в
работе. Прогресс — в `counts`.

### 2.4 Забрать результаты

    GET /api/v1/batches/{batch_id}/results?after=0&limit=100

Ответ — страница результатов и курсор `next_after`. Пустой `next_after` — это
последняя страница.

```json
{"batch_id": "9f2c…", "status": "done", "next_after": 4512, "items": [ { "…": "результат статьи, §3" } ]}
```

`changed_since=<ISO-время>` отдаёт только результаты, изменённые после этого
момента: исправления оператора и перепроверки (§3.3).

### 2.5 Повторить ошибочные

    POST /api/v1/batches/{batch_id}/retry

Статьи с `state: "error"` снова встают в очередь. Пустой текст не
повторяется — его нужно передать заново.

---

## 3. Результат статьи

```json
{
  "article_id": "123456",
  "check_id": "6b1e…",
  "state": "done",
  "status": "Не соответствует",
  "reason_code": "municipal",
  "reason_short": "Новость от муниципального органа. Нет упоминаний региональных органов управления",
  "reason_full": "Новость пресс-службы администрации г.о. Дубна о постановке дома на кадастровый учёт. Отсылок к губернатору, органам, учреждениям или программам МО в тексте нет.",
  "keywords": [],
  "synonyms": [],
  "governor_ref": false,
  "needs_review": false,
  "review_reasons": [],
  "duplicate_of": null,
  "revision": 1,
  "origin": "model",
  "corrected": false,
  "dict_version": 14,
  "stale": false,
  "model": "qwen3.6-35b-a3b",
  "checked_at": "2026-10-31T23:10:05+03:00"
}
```

### 3.1 Поля

| Поле | Что |
|---|---|
| `state` | `done` — есть вердикт; `pending` — в работе; `skipped` — без пометки ГЗ; `error` — не проверена |
| `status` | «Соответствует» / «Не соответствует»; `null`, если вердикта нет |
| `reason_code`, `reason_short` | краткая причина при «Не соответствует», иначе `null` |
| `reason_full` | развёрнутое обоснование: как по тексту принято решение — и при соответствии, и при несоответствии |
| `keywords` | при «Соответствует»: `[{code, id, title, evidence}]`, `id` — id значения в Терминале, `evidence` — цитата из статьи |
| `synonyms` | при «Соответствует»: `[{code, id, phrases: [{text, status}]}]` — как значение названо в этой статье; `approved` — синоним одобрен, `proposed` — на модерации |
| `governor_ref` | в тексте есть отсылка к Губернатору Московской области |
| `needs_review`, `review_reasons` | вердикт окончательный, но стоит взглянуть человеку; почему — в `review_reasons` |
| `duplicate_of` | `id` первой статьи пачки с тем же текстом |
| `revision`, `origin`, `corrected` | номер ревизии; `model` / `recheck` / `human`; `true`, если вердикт исправил оператор |
| `stale` | справочник изменился после проверки |

### 3.2 Краткие причины

| `reason_code` | `reason_short` |
|---|---|
| `crime` | Криминальный характер |
| `ad` | Рекламный характер |
| `municipal` | Новость от муниципального органа. Нет упоминаний региональных органов управления |
| `no_match` | Не найдено совпадений ни с одним ключевым значением |

Материал не о Московской области и частный медицинский случай («удалили
опухоль пациентке») — тоже `no_match`; подробности — в `reason_full`.

### 3.3 Как устроена проверка

1. **Криминал или реклама** → «Не соответствует», дальше не анализируется.
2. **Уровень.** Муниципальная новость (или управление федерального органа по МО)
   без отсылки к губернатору, органу, учреждению или программе МО → «Не
   соответствует». Формального упоминания в «бэке» достаточно.
3. **Ключевые значения.** Связь хотя бы с одним активным значением справочника →
   «Соответствует».

Каждый факт модель подтверждает цитатой; цитату, которой нет в тексте, сервис
отбрасывает. Вердикт выносит код по этим фактам, а не модель.

**Ревизии.** Оператор может исправить вердикт или отправить статью на
перепроверку. Тогда появляется новая ревизия, `/results` отдаёт только её, а по
пачке уходит callback `batch.updated`.

### 3.4 Пустые и короткие тексты

Пустой после очистки текст (например, только «Читайте также») — `state:
"error"`, `error_code: "empty_text"`: «Пустой текст статьи: <id>. Передайте полный
текст статьи.» Текст короче 150 символов проверяется, но с поводом
`short_text`.

---

## 4. Одна статья синхронно

    POST /api/v1/check
    GET  /api/v1/checks/{check_id}

Тело — одна статья из §2.2. Сервис ждёт до 50 с и возвращает результат §3. Не
успел (очередь занята пачкой) — `202 {"state": "pending", "check_id": "…"}`;
результат потом — по `GET /api/v1/checks/{check_id}`. Синхронное окно
гарантируется только при свободном слоте модели — для месячных объёмов
используйте пачки.

---

## 5. Справочник ключевых значений

    GET /api/v1/keywords?year=2026            право keywords:read
    PUT /api/v1/keywords/snapshot             право keywords:write

`GET` возвращает активные значения с одобренными синонимами:
`{year, dict_version, items: [{code, id, title, description, synonyms}]}`.

`PUT` — полный список справочника Терминала:

```json
{"year": 2026, "items": [{"id": 123, "title": "Безопасный регион", "description": "", "is_active": true,
                          "updated_at": "2026-10-14T12:30:00+03:00"}]}
```

Записи связываются по `id`, а при первой встрече — по названию без учёта
кавычек «» и регистра. Значения, которых нет в списке, выключаются. В ответе —
отчёт и список непривязанных. Тот же список можно передать полем `keywords`
при открытии пачки.

### Вебхук справочника

    POST /hooks/terminal/keywords

Формат — по `webhook.md`: событие `state_task_keyword`, действия `created`,
`updated`, `deleted`, подпись `X-WEBHOOK-SIGNATURE` — hex от
`HMAC-SHA256(secret, тело)`.

| Ответ | Когда |
|---|---|
| `200 {"status": "ok"}` | событие принято (в том числе устаревшее или повторное — оно игнорируется) |
| `401 {"detail": "Missing webhook signature"}` | нет подписи |
| `401 {"detail": "Invalid webhook signature"}` | подпись не сошлась |
| `404` | приём вебхуков выключен в админке |
| `413` | тело больше 64 КБ |
| `422` | не то событие или действие |

Событие применяется, только если его `updated_at` новее сохранённого.
`deleted` выключает значение, история проверок сохраняется.

---

## 6. Callback

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

```
POST <callback_url>
Content-Type: application/json; charset=utf-8
X-Gz-Event: batch.done
X-Gz-Batch-Id: 9f2c…
X-Gz-Timestamp: 1761937805
X-Gz-Signature: 5d0f…

{"batch_id": "9f2c…", "event": "batch.done", "status": "done", "external_ref": "сентябрь-2026",
 "counts": {"done": 498, "error": 0, "skipped": 2, "total": 500, "…": "…"}}
```

* `batch.done` — пачка обработана; `batch.updated` — изменилась ревизия
  результата (§3.3).
* В теле только счётчики — результаты забирайте через `/results` ключом.
* Ответ `2xx` — доставлено. Иначе повтор: 5 → 10 → 20 с, всего 4 попытки.
  Доставки переживают перезапуск сервиса.
* Только `https`, внутренние адреса запрещены, редиректы не выполняются.

---

## 7. Подпись трафика

Обе подписи — HMAC-SHA256, результат в hex.

| Заголовок | Направление | Подписываемые данные |
|---|---|---|
| `X-WEBHOOK-SIGNATURE` | Терминал → сервис | тело запроса |
| `X-Gz-Signature` + `X-Gz-Timestamp` | сервис → клиент | `<ts>.<batch_id>.` + тело |

Проверка callback на Python:

```python
import hashlib, hmac, time

def verify_callback(secret: str, headers: dict, body: bytes, max_skew: int = 300) -> bool:
    ts = int(headers["X-Gz-Timestamp"])
    if abs(time.time() - ts) > max_skew:
        return False
    data = f"{ts}.{headers['X-Gz-Batch-Id']}.".encode() + body
    expected = hmac.new(secret.encode(), data, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, headers["X-Gz-Signature"].lower())
```

Подпись вебхука на стороне Терминала:

```python
signature = hmac.new(secret.encode(), body_bytes, hashlib.sha256).hexdigest()
```

Сервис принимает подпись и от сырых байтов тела, и от
`json.dumps(obj, ensure_ascii=False)` — как в примере `webhook.md`.

---

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

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

BASE, KEY = "https://gz.example/api/v1", "sk-…"

def call(method, path, body=None):
    data = json.dumps(body, ensure_ascii=False).encode("utf-8") if body is not None else None
    req = urllib.request.Request(BASE + path, data=data, method=method, headers={
        "Authorization": f"Bearer {KEY}", "Content-Type": "application/json; charset=utf-8"})
    with urllib.request.urlopen(req, timeout=60) as r:
        return json.loads(r.read())

articles = [{"id": "1", "title": "…", "text": "…"}]          # статьи месяца из Терминала
batch = call("POST", "/batches", {"period": "2026-09", "external_ref": "сентябрь"})
bid = batch["batch_id"]
for i in range(0, len(articles), 500):
    call("POST", f"/batches/{bid}/articles", {"articles": articles[i:i + 500]})
call("POST", f"/batches/{bid}/close")
while call("GET", f"/batches/{bid}")["status"] != "done":
    time.sleep(60)
after, results = 0, []
while True:
    page = call("GET", f"/batches/{bid}/results?after={after}&limit=500")
    results += page["items"]
    if not page["next_after"]:
        break
    after = page["next_after"]
```

`ensure_ascii=False` здесь важен: с экранированием `\uXXXX` кириллица
занимает втрое больше места, и пачка быстрее упрётся в лимит тела.

---

## 9. Лимиты

| Параметр | Значение |
|---|---|
| Тело запроса | 20 МБ (вебхук — 64 КБ) |
| Статей в одном запросе | 500 |
| Длина текста статьи | 100 000 символов |
| Статей в очереди на ключ | задаёт администратор (Терминал — 50 000) |
| Синхронное окно `/check` | 50 с |
| Скорость | ~1 500–3 000 уникальных текстов в час на общий LM Studio |

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

---

## 10. Ошибки

Формат всегда один: `{"error": "…"}`, иногда с `"code"`. Тексты рассчитаны на
человека: что случилось и что сделать.

| Код | Пример |
|---|---|
| 400 | `Адрес callback недопустим: внутренний адрес. Укажите публичный https-адрес.` |
| 404 | `Пачка не найдена: <id>. Проверьте идентификатор.` — в том числе чужая пачка |
| 409 | `Статья 123 уже передана в этой пачке с другим текстом. Откройте новую пачку.` |
| 409 | `Пачка … уже закрыта. Откройте новую пачку для новых статей.` |
| 413 | `Тело запроса слишком велико: 24.1 МБ при лимите 20 МБ. Разбейте пачку на части.` |
| 413 | `Максимум 500 статей за запрос, передано 800. Разбейте пачку на части.` |
| 422 | `Неверный запрос: articles.0.id — Field required. Исправьте тело запроса.` |
| 429 | `В очереди уже 50000 статей этого ключа — это предел. Дождитесь обработки текущих пачек.` |
| 503 | `Справочник ключевых значений не загружен. …` |

`pending` — не ошибка. Временная недоступность модели ошибкой тоже не
становится: проверка ждёт и повторяется сама.

---

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

| Метод | Назначение |
|---|---|
| `GET /health` | живость: `{"status": "ok", "service": "gz"}` |
| `GET /health?deep=1` | БД и модель; нужен `X-Health-Token` или вход в админку |
| `GET /docs` | эта инструкция |
| `GET /docs/api.md` | её исходник в markdown |
