Срез 0: скелет scan-gateway — без фронта и БД, горячие папки local/smb, воркер NATS без JetStream, эмулятор ИС Сканирование #3

Merged
aep merged 68 commits from feat/1-slice-0-skeleton into main 2026-10-04 18:41:20 +03:00
6 changed files with 319 additions and 63 deletions
Showing only changes of commit 24af6f1b9a - Show all commits

View File

@ -56,7 +56,8 @@ SCAN_MAX_INFLIGHT=4
# --- Эмулятор ИС Сканирование (python -m src.scan_emulator, make emulator; только dev) --- # --- Эмулятор ИС Сканирование (python -m src.scan_emulator, make emulator; только dev) ---
# Эти параметры читает сам эмулятор, сервис шлюза их не использует. Корень горячей папки и подкаталоги # Эти параметры читает сам эмулятор, сервис шлюза их не использует. Корень горячей папки и подкаталоги
# эмулятор берёт из SCAN_HOTFOLDER_PATH / SCAN_INPUT_DIR / SCAN_OUTPUT_DIR выше. # эмулятор берёт из SCAN_HOTFOLDER_PATH / SCAN_INPUT_DIR / SCAN_OUTPUT_DIR выше; набор ответа на задание он собирает
# во временном каталоге SCAN_TMP_DIR (выше) и переименовывает в <Output>/<uuid>/ целиком.
# Каталог с фикстурами ответов ИС (aep_N.json); путь от api/ (репозиторий fixtures лежит рядом с workspace) # Каталог с фикстурами ответов ИС (aep_N.json); путь от api/ (репозиторий fixtures лежит рядом с workspace)
SCAN_FIXTURES_DIR=../../fixtures/ocr SCAN_FIXTURES_DIR=../../fixtures/ocr
# Задержка перед выдачей ответа, секунды (имитация работы ИС) # Задержка перед выдачей ответа, секунды (имитация работы ИС)

View File

@ -1,15 +1,19 @@
"""Эмулятор ИС Сканирование (Content AI) для разработки и тестов (D-6, R-11, spec scan-emulator). """Эмулятор ИС Сканирование (Content AI) для разработки и тестов (D-6, R-11, spec scan-emulator).
Воспроизводит протокол горячих папок: для нового файла `<uuid>_<name>.<ext>` во входящем каталоге через задержку кладёт Воспроизводит протокол горячих папок: для нового файла `<uuid>_<name>.<ext>` во входящем каталоге через задержку кладёт
`<output>/<uuid>/<uuid>.json` с эталонным ответом из `SCAN_FIXTURES_DIR` и переименовывает входящий файл в `….done`. каталог `<output>/<uuid>/` с набором эталонных ответов из `SCAN_FIXTURES_DIR` (все `<name>.json` -> `<uuid>.json` и
Эмулятор читает собственные переменные окружения (`EmulatorSettings`), а не настройки шлюза: в его контейнере нет `<name>_<suffix>.json` -> `<uuid>_<suffix>.json`) и переименовывает входящий файл в `….done`. Набор собирается во
переменных MinIO/NATS. С каталогами работает через `LocalHotFolder`, тем самым тестируя и его. временном каталоге служебной папки (`SCAN_TMP_DIR`, та же ФС, что и `<output>`) и появляется в `<output>` одним rename
каталога: шлюз не видит частичного набора. Эмулятор читает собственные переменные окружения (`EmulatorSettings`), а не
настройки шлюза: в его контейнере нет переменных MinIO/NATS. С каталогами работает через `LocalHotFolder`,
тем самым тестируя и его.
""" """
import json import json
import math import math
import os import os
import re import re
import shutil
import threading import threading
import time import time
from collections.abc import Mapping from collections.abc import Mapping
@ -54,6 +58,7 @@ class EmulatorSettings:
hotfolder_path: Path hotfolder_path: Path
input_dir: str = "Input" input_dir: str = "Input"
output_dir: str = "Output" output_dir: str = "Output"
tmp_dir: str = "Tmp"
fixtures_dir: Path = Path("../../fixtures/ocr") fixtures_dir: Path = Path("../../fixtures/ocr")
delay: float = 2.0 delay: float = 2.0
poll_interval: float = 1.0 poll_interval: float = 1.0
@ -63,8 +68,8 @@ class EmulatorSettings:
"""Разбор окружения. Без аргумента: `os.environ` поверх файла `ENV_FILE` (по умолчанию `.env`, если он есть). """Разбор окружения. Без аргумента: `os.environ` поверх файла `ENV_FILE` (по умолчанию `.env`, если он есть).
`SCAN_HOTFOLDER_PATH` обязателен (`ValueError`); `SCAN_INPUT_DIR`=Input, `SCAN_OUTPUT_DIR`=Output, `SCAN_HOTFOLDER_PATH` обязателен (`ValueError`); `SCAN_INPUT_DIR`=Input, `SCAN_OUTPUT_DIR`=Output,
`SCAN_FIXTURES_DIR`=../../fixtures/ocr, `SCAN_EMULATOR_DELAY`=2 (с, >= 0), `SCAN_TMP_DIR`=Tmp (служебный каталог для сборки набора ответа), `SCAN_FIXTURES_DIR`=../../fixtures/ocr,
`SCAN_EMULATOR_POLL_SECONDS`=1 (с, > 0). `SCAN_EMULATOR_DELAY`=2 (с, >= 0), `SCAN_EMULATOR_POLL_SECONDS`=1 (с, > 0).
""" """
if environ is None: if environ is None:
env_file = os.environ.get("ENV_FILE", ".env") env_file = os.environ.get("ENV_FILE", ".env")
@ -79,74 +84,125 @@ class EmulatorSettings:
hotfolder_path=Path(path), hotfolder_path=Path(path),
input_dir=env.get("SCAN_INPUT_DIR") or "Input", input_dir=env.get("SCAN_INPUT_DIR") or "Input",
output_dir=env.get("SCAN_OUTPUT_DIR") or "Output", output_dir=env.get("SCAN_OUTPUT_DIR") or "Output",
tmp_dir=env.get("SCAN_TMP_DIR") or "Tmp",
fixtures_dir=Path(env.get("SCAN_FIXTURES_DIR") or "../../fixtures/ocr"), fixtures_dir=Path(env.get("SCAN_FIXTURES_DIR") or "../../fixtures/ocr"),
delay=_read_float(env, "SCAN_EMULATOR_DELAY", 2.0, positive=False), delay=_read_float(env, "SCAN_EMULATOR_DELAY", 2.0, positive=False),
poll_interval=_read_float(env, "SCAN_EMULATOR_POLL_SECONDS", 1.0, positive=True), poll_interval=_read_float(env, "SCAN_EMULATOR_POLL_SECONDS", 1.0, positive=True),
) )
_EMPTY_FIELD: dict[str, Any] = {"form_name": "", "confidence": 0}
def _error_answer(job_id: str, message: str) -> dict[str, Any]: def _error_answer(job_id: str, message: str) -> dict[str, Any]:
return {"file_uid": job_id, "error": {"code": _ERROR_CODE, "message": message}, "data_ocr": []} """Ответ-ошибка в форме реальных ответов Content AI (`aep_5.json`): `data_ocr` — объект с пустыми полями (R-28)."""
data_ocr: dict[str, Any] = {
field: dict(_EMPTY_FIELD) for field in ("label", "organization", "inn", "units", "date")
}
data_ocr["table_structure"] = []
return {"file_uid": job_id, "error": {"code": _ERROR_CODE, "message": message}, "data_ocr": data_ocr}
def _find_fixture(fixtures: Path, stem: str) -> Path | None: def _select_fixtures(fixtures: Path, stem: str) -> list[tuple[str, Path]]:
"""`<stem>.json`, затем `<stem>*.json` (первый по алфавиту). Имя сравнивается буквально, без glob.""" """Фикстуры задания: `<stem>.json` и `<stem>_*.json` -> `(suffix, путь)`, `suffix` пуст для `<stem>.json`.
Имя сравнивается буквально, без glob; расширение `.json` — без учёта регистра. Не префикс `<stem>*`: `aep_30.json`
не входит в набор `aep_3`. Результат отсортирован по имени файла; при совпадении `suffix` берётся первый файл.
"""
if not stem:
return []
try: try:
names = sorted(p.name for p in fixtures.iterdir() if p.name.lower().endswith(".json") and p.is_file()) names = sorted(p.name for p in fixtures.iterdir() if p.name.lower().endswith(".json") and p.is_file())
except OSError: except OSError:
return None return []
if f"{stem}.json" in names: selected: dict[str, Path] = {}
return fixtures / f"{stem}.json" for fixture_name in names:
for name in names: base = fixture_name[: -len(".json")]
if name.startswith(stem): if base == stem:
return fixtures / name suffix = ""
return None elif base.startswith(f"{stem}_") and len(base) > len(stem) + 1:
suffix = base[len(stem) + 1 :]
else:
continue
selected.setdefault(suffix, fixtures / fixture_name)
return list(selected.items())
def _build_answer(job_id: str, name: str, fixtures: Path) -> dict[str, Any]: def _answer_file_name(job_id: str, suffix: str) -> str:
"""Ответ ИС на файл `name` (без префикса uuid): фикстура с подставленным `file_uid` либо ошибка Content AI.""" return f"{job_id}_{suffix}.json" if suffix else f"{job_id}.json"
fixture = _find_fixture(fixtures, Path(name).stem)
if fixture is None:
return _error_answer(job_id, f"{name}: нет эталонного ответа в фикстурах") def _build_answers(job_id: str, name: str, fixtures: Path) -> dict[str, dict[str, Any]]:
"""Набор ответов ИС на файл `name` (без префикса uuid): `{имя файла ответа: ответ}`.
Каждый ответ — фикстура с подставленным `file_uid`; повреждённая фикстура даёт на своём месте ответ-ошибку; если
фикстур нет совсем — один ответ `<uuid>.json` с `INVALID_DOCUMENT`.
"""
selected = _select_fixtures(fixtures, Path(name).stem)
if not selected:
return {_answer_file_name(job_id, ""): _error_answer(job_id, f"{name}: нет эталонного ответа в фикстурах")}
answers: dict[str, dict[str, Any]] = {}
for suffix, fixture in selected:
try: try:
data = json.loads(fixture.read_text(encoding="utf-8")) data = json.loads(fixture.read_text(encoding="utf-8"))
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("корень не объект JSON") raise ValueError("корень не объект JSON")
except (OSError, ValueError) as exc: # json.JSONDecodeError и UnicodeDecodeError — подклассы ValueError except (OSError, ValueError) as exc: # json.JSONDecodeError и UnicodeDecodeError — подклассы ValueError
logger.error("Эмулятор: фикстура {} повреждена ({}); для {} отдаём {}", fixture, exc, name, _ERROR_CODE) logger.error("Эмулятор: фикстура {} повреждена ({}); для {} отдаём {}", fixture, exc, name, _ERROR_CODE)
return _error_answer(job_id, f"{name}: эталонный ответ {fixture.name} повреждён и не разобран") answers[_answer_file_name(job_id, suffix)] = _error_answer(
return {"file_uid": job_id, **{key: value for key, value in data.items() if key != "file_uid"}} job_id, f"{name}: эталонный ответ {fixture.name} повреждён и не разобран"
)
continue
answers[_answer_file_name(job_id, suffix)] = {
"file_uid": job_id,
**{key: value for key, value in data.items() if key != "file_uid"},
}
return answers
def _write_answer(hotfolder: LocalHotFolder, job_id: str, answer: dict[str, Any]) -> None: def _write_json(path: Path, answer: dict[str, Any]) -> None:
"""Атомарная запись ответа: `.part` в том же каталоге, затем `os.replace`.""" path.write_text(json.dumps(answer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
target = hotfolder.answer_path(job_id)
target.parent.mkdir(parents=True, exist_ok=True)
part = target.parent / f".{target.name}.part" def _publish_answers(hotfolder: LocalHotFolder, job_id: str, answers: dict[str, dict[str, Any]]) -> None:
"""Атомарная публикация набора (R-26): файлы пишутся в `<tmp>/.<uuid>.answer`, затем каталог переименовывается в
`<output>/<uuid>/`. Служебный и исходящий каталоги лежат в одном корне (одна ФС), поэтому rename атомарен, а
каталог ответа либо отсутствует, либо содержит весь набор. Каталог ответа, оставшийся от прошлого запуска,
заменяется (единственный момент, когда его на время нет). При сбое временный каталог удаляется."""
target = hotfolder.answer_dir(job_id)
hotfolder.tmp_dir.mkdir(exist_ok=True)
hotfolder.output_dir.mkdir(parents=True, exist_ok=True)
staging = hotfolder.tmp_dir / f".{job_id}.answer"
shutil.rmtree(staging, ignore_errors=True) # остаток прерванной сборки
try: try:
part.write_text(json.dumps(answer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") staging.mkdir()
os.replace(part, target) for file_name, answer in answers.items():
_write_json(staging / file_name, answer)
if target.exists():
shutil.rmtree(target)
os.replace(staging, target)
except BaseException: except BaseException:
part.unlink(missing_ok=True) shutil.rmtree(staging, ignore_errors=True)
raise raise
def process_once( def process_once( # noqa: PLR0913 — корень, фикстуры, задержка + имена трёх служебных каталогов
root: Path | str, root: Path | str,
fixtures: Path | str, fixtures: Path | str,
delay: float, delay: float,
*, *,
input_dir: str = "Input", input_dir: str = "Input",
output_dir: str = "Output", output_dir: str = "Output",
tmp_dir: str = "Tmp",
) -> list[str]: ) -> list[str]:
"""Один проход по входящему каталогу; возвращает uuid обработанных файлов. """Один проход по входящему каталогу; возвращает uuid обработанных файлов.
Берёт новые `<uuid>_<name>.<ext>` (не dot-файлы, не `*.done`, не каталоги), при наличии работы один раз за проход Берёт новые `<uuid>_<name>.<ext>` (не dot-файлы, не `*.done`, не каталоги), при наличии работы один раз за проход
ждёт `delay` секунд, для каждого файла пишет `<output>/<uuid>/<uuid>.json` и только затем переименовывает входящий ждёт `delay` секунд, для каждого файла публикует набор ответов `<output>/<uuid>/` (атомарно, через `tmp_dir`) и
файл в `….done`. Имена не по шаблону `<uuid>_` игнорируются (одно предупреждение за проход). Нет входящего только затем переименовывает входящий файл в `….done`. Имена не по шаблону `<uuid>_` игнорируются (одно
каталога — пустой результат. предупреждение за проход). Нет входящего каталога — пустой результат.
""" """
hotfolder = LocalHotFolder(root, input_dir, output_dir) hotfolder = LocalHotFolder(root, input_dir, output_dir, tmp_dir)
fixtures_dir = Path(fixtures) fixtures_dir = Path(fixtures)
try: try:
entries = sorted(hotfolder.input_dir.iterdir(), key=lambda p: p.name) entries = sorted(hotfolder.input_dir.iterdir(), key=lambda p: p.name)
@ -171,13 +227,13 @@ def process_once(
processed: list[str] = [] processed: list[str] = []
for entry, job_id, name in pending: for entry, job_id, name in pending:
try: try:
_write_answer(hotfolder, job_id, _build_answer(job_id, name, fixtures_dir)) _publish_answers(hotfolder, job_id, _build_answers(job_id, name, fixtures_dir))
os.replace(entry, entry.with_name(entry.name + _DONE_SUFFIX)) os.replace(entry, entry.with_name(entry.name + _DONE_SUFFIX))
except FileNotFoundError: except FileNotFoundError:
logger.warning("Эмулятор: файл {} исчез во время обработки", entry.name) logger.warning("Эмулятор: файл {} исчез во время обработки", entry.name)
continue continue
processed.append(job_id) processed.append(job_id)
logger.info("Эмулятор: {} -> {}", entry.name, hotfolder.answer_path(job_id)) logger.info("Эмулятор: {} -> {}", entry.name, hotfolder.answer_dir(job_id))
return processed return processed
@ -188,7 +244,7 @@ def _count_fixtures(fixtures: Path) -> int:
def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: int | None = None) -> None: def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: int | None = None) -> None:
"""Цикл эмулятора: `process_once` каждые `poll_interval` секунд, пока не взведён `stop` (или не выполнено """Цикл эмулятора: `process_once` каждые `poll_interval` секунд, пока не взведён `stop` (или не выполнено
`max_iterations` проходов — для тестов). Сбой прохода логируется и не останавливает цикл.""" `max_iterations` проходов — для тестов). Сбой прохода логируется и не останавливает цикл."""
hotfolder = LocalHotFolder(settings.hotfolder_path, settings.input_dir, settings.output_dir) hotfolder = LocalHotFolder(settings.hotfolder_path, settings.input_dir, settings.output_dir, settings.tmp_dir)
logger.info( logger.info(
"Эмулятор ИС Сканирование: корень {}, входящий каталог {}, исходящий каталог {}, задержка {} с, опрос {} с", "Эмулятор ИС Сканирование: корень {}, входящий каталог {}, исходящий каталог {}, задержка {} с, опрос {} с",
hotfolder.root, hotfolder.root,
@ -212,6 +268,7 @@ def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: in
settings.delay, settings.delay,
input_dir=settings.input_dir, input_dir=settings.input_dir,
output_dir=settings.output_dir, output_dir=settings.output_dir,
tmp_dir=settings.tmp_dir,
) )
except Exception: # noqa: BLE001 — эмулятор живёт, пока его не остановят except Exception: # noqa: BLE001 — эмулятор живёт, пока его не остановят
logger.exception("Эмулятор: сбой прохода по входящему каталогу") logger.exception("Эмулятор: сбой прохода по входящему каталогу")

View File

@ -1,7 +1,8 @@
"""Эмулятор ИС Сканирование (spec scan-emulator, D-6, R-11, slice-0-skeleton). """Эмулятор ИС Сканирование (spec scan-emulator, D-6, R-11, slice-0-skeleton).
Сценарии spec «Точное имя», «Префикс», «Нет пары» — на копиях реальных `../fixtures/ocr/*.json` (маркер `fixtures`); Сценарии spec «Точное имя», «Суффикс», «Несколько файлов на одно задание», «Нет пары» — на копиях реальных
остальное — на синтетических фикстурах в `tmp_path`, без внешних репозиториев и без Docker. `../fixtures/ocr/*.json` (маркер `fixtures`); остальное — на синтетических фикстурах в `tmp_path`, без внешних
репозиториев и без Docker.
""" """
import json import json
@ -18,7 +19,10 @@ from typing import Any
import pytest import pytest
from loguru import logger from loguru import logger
import src.scan_emulator as emulator
from src.gateway.hotfolder import LocalHotFolder, inbox_name from src.gateway.hotfolder import LocalHotFolder, inbox_name
from src.gateway.merge import merge_answers
from src.normalizer import NullNormalizer
from src.scan_emulator import EmulatorSettings, process_once, run from src.scan_emulator import EmulatorSettings, process_once, run
from src.scan_emulator.__main__ import main from src.scan_emulator.__main__ import main
@ -53,11 +57,28 @@ def _drop(hf: LocalHotFolder, name: str, data: bytes = b"%PDF") -> str:
return job_id return job_id
def _answer(hf: LocalHotFolder, job_id: str) -> dict[str, Any]: def _answer(hf: LocalHotFolder, job_id: str, name: str | None = None) -> dict[str, Any]:
result: dict[str, Any] = json.loads(hf.answer_path(job_id).read_text(encoding="utf-8")) """Файл ответа каталога `Output/<uuid>/` (по умолчанию основной `<uuid>.json`)."""
path = hf.answer_path(job_id) if name is None else hf.answer_dir(job_id) / name
result: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
return result return result
def _names(hf: LocalHotFolder, job_id: str) -> list[str]:
return sorted(p.name for p in hf.answer_dir(job_id).iterdir())
# форма `data_ocr` реального ответа-ошибки ИС (aep_5.json); R-28
EMPTY_DATA_OCR: dict[str, Any] = {
"label": {"form_name": "", "confidence": 0},
"organization": {"form_name": "", "confidence": 0},
"inn": {"form_name": "", "confidence": 0},
"units": {"form_name": "", "confidence": 0},
"date": {"form_name": "", "confidence": 0},
"table_structure": [],
}
@pytest.fixture @pytest.fixture
def log_messages() -> Iterator[list[str]]: def log_messages() -> Iterator[list[str]]:
messages: list[str] = [] messages: list[str] = []
@ -90,43 +111,102 @@ class TestRealFixtures:
assert (hf.input_dir / f"{job_id}_{name}.done").is_file() assert (hf.input_dir / f"{job_id}_{name}.done").is_file()
assert not (hf.input_dir / f"{job_id}_{name}").exists() assert not (hf.input_dir / f"{job_id}_{name}").exists()
def test_prefix(self, tmp_path: Path, real_fixtures: Path) -> None: def test_suffix(self, tmp_path: Path, real_fixtures: Path) -> None:
"""`aep_8.pdf` -> `aep_8_form4.json` как `<uuid>_form4.json` (раньше — `<uuid>.json`, правило `<name>*`)."""
hf = _hotfolder(tmp_path) hf = _hotfolder(tmp_path)
job_id = _drop(hf, "aep_8.pdf") job_id = _drop(hf, "aep_8.pdf")
process_once(hf.root, real_fixtures, 0) process_once(hf.root, real_fixtures, 0)
expected = json.loads((real_fixtures / "aep_8_form4.json").read_text(encoding="utf-8")) expected = json.loads((real_fixtures / "aep_8_form4.json").read_text(encoding="utf-8"))
expected["file_uid"] = job_id expected["file_uid"] = job_id
assert _answer(hf, job_id) == expected assert _names(hf, job_id) == [f"{job_id}_form4.json"]
assert _answer(hf, job_id, f"{job_id}_form4.json") == expected
assert (hf.input_dir / f"{job_id}_aep_8.pdf.done").is_file() assert (hf.input_dir / f"{job_id}_aep_8.pdf.done").is_file()
def test_two_files_for_one_job(self, tmp_path: Path, real_fixtures: Path) -> None:
hf = _hotfolder(tmp_path)
job_id = _drop(hf, "aep_3.pdf")
assert process_once(hf.root, real_fixtures, 0) == [job_id]
assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"]
main = json.loads((real_fixtures / "aep_3.json").read_text(encoding="utf-8"))
ofr = json.loads((real_fixtures / "aep_3_ofr.json").read_text(encoding="utf-8"))
assert main["error"] == {"code": "", "message": ""}
assert ofr["error"]["code"] == "INVALID_DOCUMENT"
assert _answer(hf, job_id) == {**main, "file_uid": job_id}
assert _answer(hf, job_id, f"{job_id}_ofr.json") == {**ofr, "file_uid": job_id}
assert (hf.input_dir / f"{job_id}_aep_3.pdf.done").is_file()
def test_single_success_file(self, tmp_path: Path, real_fixtures: Path) -> None:
hf = _hotfolder(tmp_path)
job_id = _drop(hf, "aep_6.pdf")
process_once(hf.root, real_fixtures, 0)
assert _names(hf, job_id) == [f"{job_id}.json"]
expected = json.loads((real_fixtures / "aep_6.json").read_text(encoding="utf-8"))
assert _answer(hf, job_id) == {**expected, "file_uid": job_id}
def test_no_pair(self, tmp_path: Path, real_fixtures: Path) -> None: def test_no_pair(self, tmp_path: Path, real_fixtures: Path) -> None:
hf = _hotfolder(tmp_path) hf = _hotfolder(tmp_path)
job_id = _drop(hf, "unknown.pdf") job_id = _drop(hf, "unknown.pdf")
process_once(hf.root, real_fixtures, 0) process_once(hf.root, real_fixtures, 0)
assert _names(hf, job_id) == [f"{job_id}.json"]
assert _answer(hf, job_id) == { assert _answer(hf, job_id) == {
"file_uid": job_id, "file_uid": job_id,
"error": {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"}, "error": {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"},
"data_ocr": [], "data_ocr": EMPTY_DATA_OCR,
} }
assert (hf.input_dir / f"{job_id}_unknown.pdf.done").is_file() assert (hf.input_dir / f"{job_id}_unknown.pdf.done").is_file()
def test_no_pair_has_the_shape_of_real_error_answers(self, tmp_path: Path, real_fixtures: Path) -> None:
"""R-28: форма `data_ocr` совпадает с реальным ответом-ошибкой `aep_5.json` (кроме `0` вместо `0.0`)."""
hf = _hotfolder(tmp_path)
job_id = _drop(hf, "unknown.pdf")
process_once(hf.root, real_fixtures, 0)
real = json.loads((real_fixtures / "aep_5.json").read_text(encoding="utf-8"))
assert _answer(hf, job_id)["data_ocr"] == real["data_ocr"]
assert list(_answer(hf, job_id)["data_ocr"]) == list(real["data_ocr"])
class TestFixtureLookup: class TestFixtureLookup:
def test_exact_beats_prefix(self, tmp_path: Path) -> None: def test_name_with_other_characters_is_not_taken(self, tmp_path: Path) -> None:
"""В набор входят `<name>.json` и `<name>_*.json`; `a_b-1.json` (нет `_` после имени) — нет."""
hf = _hotfolder(tmp_path) hf = _hotfolder(tmp_path)
# `a_b-1.json` по алфавиту раньше `a_b.json` ('-' 0x2D < '.' 0x2E): без шага «точное имя» победил бы префикс fixtures = _fixtures_dir(tmp_path, {"a_b-1.json": {"v": "dash"}, "a_b.json": {"file_uid": "x", "v": "exact"}})
assert sorted(["a_b.json", "a_b-1.json"])[0] == "a_b-1.json"
fixtures = _fixtures_dir(tmp_path, {"a_b-1.json": {"v": "prefix"}, "a_b.json": {"file_uid": "x", "v": "exact"}})
job_id = _drop(hf, "a_b.pdf") job_id = _drop(hf, "a_b.pdf")
process_once(hf.root, fixtures, 0) process_once(hf.root, fixtures, 0)
assert _names(hf, job_id) == [f"{job_id}.json"]
assert _answer(hf, job_id) == {"file_uid": job_id, "v": "exact"} assert _answer(hf, job_id) == {"file_uid": job_id, "v": "exact"}
def test_prefix_first_alphabetical(self, tmp_path: Path) -> None: def test_all_suffix_fixtures_are_taken(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path) hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a_zz.json": {"v": "zz"}, "a_b.json": {"v": "b"}, "a_c.json": {"v": "c"}}) fixtures = _fixtures_dir(tmp_path, {"a_zz.json": {"v": "zz"}, "a_b.json": {"v": "b"}, "a_c.json": {"v": "c"}})
job_id = _drop(hf, "a.pdf") job_id = _drop(hf, "a.pdf")
process_once(hf.root, fixtures, 0) process_once(hf.root, fixtures, 0)
assert _answer(hf, job_id)["v"] == "b" assert _names(hf, job_id) == [f"{job_id}_b.json", f"{job_id}_c.json", f"{job_id}_zz.json"]
assert [_answer(hf, job_id, f"{job_id}_{s}.json")["v"] for s in ("b", "c", "zz")] == ["b", "c", "zz"]
def test_foreign_prefix_is_not_taken(self, tmp_path: Path) -> None:
"""`aep_3` не забирает `aep_30*`: правило `<name>.json` и `<name>_*.json`, а не `<name>*`."""
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(
tmp_path,
{
"aep_3.json": {"v": "main"},
"aep_3_ofr.json": {"v": "ofr"},
"aep_30.json": {"v": 30},
"aep_30_x.json": {"v": 31},
},
)
job_id = _drop(hf, "aep_3.pdf")
process_once(hf.root, fixtures, 0)
assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"]
assert _answer(hf, job_id)["v"] == "main"
assert _answer(hf, job_id, f"{job_id}_ofr.json")["v"] == "ofr"
def test_underscore_without_suffix_is_not_taken(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a_.json": {"v": 1}})
job_id = _drop(hf, "a.pdf")
process_once(hf.root, fixtures, 0)
assert _answer(hf, job_id)["error"]["code"] == "INVALID_DOCUMENT"
def test_file_uid_added_when_absent_and_replaced_when_present(self, tmp_path: Path) -> None: def test_file_uid_added_when_absent_and_replaced_when_present(self, tmp_path: Path) -> None:
hf = _hotfolder(tmp_path) hf = _hotfolder(tmp_path)
@ -171,7 +251,7 @@ class TestFixtureLookup:
assert process_once(hf.root, fixtures, 0) == [job_id] assert process_once(hf.root, fixtures, 0) == [job_id]
answer = _answer(hf, job_id) answer = _answer(hf, job_id)
assert answer["file_uid"] == job_id assert answer["file_uid"] == job_id
assert answer["data_ocr"] == [] assert answer["data_ocr"] == EMPTY_DATA_OCR
assert answer["error"]["code"] == "INVALID_DOCUMENT" assert answer["error"]["code"] == "INVALID_DOCUMENT"
assert "a.pdf" in answer["error"]["message"] assert "a.pdf" in answer["error"]["message"]
assert "a.json" in answer["error"]["message"] assert "a.json" in answer["error"]["message"]
@ -283,6 +363,120 @@ class TestProcessOnce:
assert json.loads(files[0].data) == {"file_uid": job_id, "data_ocr": [{"k": "v"}]} assert json.loads(files[0].data) == {"file_uid": job_id, "data_ocr": [{"k": "v"}]}
class TestAnswerSet:
def _two_file_job(self, tmp_path: Path) -> tuple[LocalHotFolder, Path, str]:
hf = _hotfolder(tmp_path)
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": "main"}, "a_ofr.json": {"v": "ofr"}})
return hf, fixtures, _drop(hf, "a.pdf")
def test_directory_appears_only_by_rename_of_complete_set(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""R-26: до rename каталога `Output/<uuid>/` нет вовсе, набор уже собран во временном каталоге `Tmp`;
шлюз (`poll`) в это время ничего не видит, после rename — видит оба файла сразу."""
hf, fixtures, job_id = self._two_file_job(tmp_path)
gateway_side = LocalHotFolder(hf.root, settle_seconds=0)
seen: dict[str, Any] = {}
original_replace = os.replace
def spy(src: Path, dst: Path) -> None:
if Path(dst) == hf.answer_dir(job_id):
seen["output_entries_before"] = sorted(p.name for p in hf.output_dir.iterdir())
seen["poll_before"] = gateway_side.poll(job_id)
seen["staging_parent"] = Path(src).parent
seen["staging_files"] = sorted(p.name for p in Path(src).iterdir())
original_replace(src, dst)
monkeypatch.setattr("src.scan_emulator.os.replace", spy)
process_once(hf.root, fixtures, 0)
assert seen["output_entries_before"] == []
assert seen["poll_before"] is None
assert seen["staging_parent"] == hf.tmp_dir
assert seen["staging_files"] == [f"{job_id}.json", f"{job_id}_ofr.json"]
after = gateway_side.poll(job_id)
assert after is not None
assert [f.name for f in after] == [f"{job_id}.json", f"{job_id}_ofr.json"]
assert list(hf.tmp_dir.iterdir()) == [] # временный каталог переименован, не скопирован
def test_failure_while_writing_leaves_no_answer_dir_and_no_done(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
hf, fixtures, job_id = self._two_file_job(tmp_path)
calls: list[Path] = []
original = emulator._write_json
def flaky(path: Path, answer: dict[str, Any]) -> None:
calls.append(path)
if len(calls) == 2:
raise OSError("диск переполнен")
original(path, answer)
monkeypatch.setattr(emulator, "_write_json", flaky)
with pytest.raises(OSError, match="диск"):
process_once(hf.root, fixtures, 0)
assert len(calls) == 2
assert not hf.answer_dir(job_id).exists()
assert list(hf.tmp_dir.iterdir()) == []
assert (hf.input_dir / f"{job_id}_a.pdf").is_file()
assert not (hf.input_dir / f"{job_id}_a.pdf.done").exists()
monkeypatch.undo()
assert process_once(hf.root, fixtures, 0) == [job_id] # следующий проход обрабатывает задание целиком
assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"]
def test_stale_staging_dir_is_discarded(self, tmp_path: Path) -> None:
hf, fixtures, job_id = self._two_file_job(tmp_path)
stale = hf.tmp_dir / f".{job_id}.answer"
stale.mkdir(parents=True)
(stale / "junk.json").write_text("{}")
process_once(hf.root, fixtures, 0)
assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"]
assert list(hf.tmp_dir.iterdir()) == []
def test_existing_answer_dir_is_replaced(self, tmp_path: Path) -> None:
hf, fixtures, job_id = self._two_file_job(tmp_path)
hf.answer_dir(job_id).mkdir(parents=True)
(hf.answer_dir(job_id) / "old.json").write_text("{}")
process_once(hf.root, fixtures, 0)
assert _names(hf, job_id) == [f"{job_id}.json", f"{job_id}_ofr.json"]
def test_custom_tmp_dir_name(self, tmp_path: Path) -> None:
root = tmp_path / "hf"
root.mkdir()
hf = LocalHotFolder(root, tmp_dir="Work")
fixtures = _fixtures_dir(tmp_path, {"a.json": {"v": 1}})
job_id = _drop(hf, "a.pdf")
assert process_once(root, fixtures, 0, tmp_dir="Work") == [job_id]
assert (root / "Work").is_dir()
assert not (root / "Tmp").exists() or list((root / "Tmp").iterdir()) == []
def test_gateway_poll_waits_for_settle_then_returns_whole_set(self, tmp_path: Path) -> None:
"""Сквозной «put -> эмулятор -> poll (settle, ложные часы) -> merge_answers -> NullNormalizer» на `aep_3`."""
if not REAL_FIXTURES.is_dir():
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
root = tmp_path / "hotfolder"
root.mkdir()
now = [100.0]
hf = LocalHotFolder(root, settle_seconds=2, clock=lambda: now[0])
job_id = _uid()
assert hf.poll(job_id) is None
hf.put(inbox_name(job_id, "aep_3.pdf"), b"%PDF-1.4")
assert process_once(root, REAL_FIXTURES, 0) == [job_id]
assert hf.poll(job_id) is None # набор впервые замечен: settle отсчитывается с этого опроса
now[0] += 1.9
assert hf.poll(job_id) is None
now[0] += 0.2
files = hf.poll(job_id)
assert files is not None
assert [f.name for f in files] == [f"{job_id}.json", f"{job_id}_ofr.json"]
merged = merge_answers(job_id, files)
outcome = NullNormalizer().normalize(merged)
assert outcome.error is not None
assert outcome.error.code == "NOT_IMPLEMENTED"
assert outcome.warnings is not None
assert len(outcome.warnings) == 1
assert outcome.warnings[0].startswith(f"{job_id}_ofr.json: INVALID_DOCUMENT: ")
class TestSettings: class TestSettings:
def test_defaults(self, tmp_path: Path) -> None: def test_defaults(self, tmp_path: Path) -> None:
settings = EmulatorSettings.from_env({"SCAN_HOTFOLDER_PATH": str(tmp_path)}) settings = EmulatorSettings.from_env({"SCAN_HOTFOLDER_PATH": str(tmp_path)})
@ -290,6 +484,7 @@ class TestSettings:
assert (settings.input_dir, settings.output_dir) == ("Input", "Output") assert (settings.input_dir, settings.output_dir) == ("Input", "Output")
assert settings.delay == 2 assert settings.delay == 2
assert settings.poll_interval == 1 assert settings.poll_interval == 1
assert settings.tmp_dir == "Tmp"
assert settings.fixtures_dir == Path("../../fixtures/ocr") assert settings.fixtures_dir == Path("../../fixtures/ocr")
def test_overrides(self, tmp_path: Path) -> None: def test_overrides(self, tmp_path: Path) -> None:
@ -298,12 +493,14 @@ class TestSettings:
"SCAN_HOTFOLDER_PATH": str(tmp_path), "SCAN_HOTFOLDER_PATH": str(tmp_path),
"SCAN_INPUT_DIR": "In", "SCAN_INPUT_DIR": "In",
"SCAN_OUTPUT_DIR": "Out", "SCAN_OUTPUT_DIR": "Out",
"SCAN_TMP_DIR": "Work",
"SCAN_FIXTURES_DIR": "fx", "SCAN_FIXTURES_DIR": "fx",
"SCAN_EMULATOR_DELAY": "0.5", "SCAN_EMULATOR_DELAY": "0.5",
"SCAN_EMULATOR_POLL_SECONDS": "0.1", "SCAN_EMULATOR_POLL_SECONDS": "0.1",
} }
) )
assert (settings.input_dir, settings.output_dir) == ("In", "Out") assert (settings.input_dir, settings.output_dir) == ("In", "Out")
assert settings.tmp_dir == "Work"
assert settings.fixtures_dir == Path("fx") assert settings.fixtures_dir == Path("fx")
assert (settings.delay, settings.poll_interval) == (0.5, 0.1) assert (settings.delay, settings.poll_interval) == (0.5, 0.1)

View File

@ -45,7 +45,7 @@
### Task 4: [R4] Эмулятор: набор файлов на задание ### Task 4: [R4] Эмулятор: набор файлов на задание
- [ ] `src/scan_emulator/`: по входящему `<uuid>_<name>.<ext>` собрать из `SCAN_FIXTURES_DIR` все `<name>.json` и `<name>_*.json` (точное правило из Global Constraints), записать в `Output/<uuid>/` под именами `<uuid>.json` / `<uuid>_<suffix>.json` с `file_uid = <uuid>`; пары нет — прежний `INVALID_DOCUMENT`; `aep_3` не забирает `aep_30*`. Тесты (маркер `fixtures`, `tmp_path`): `aep_3.pdf` → два файла (`<uuid>.json` успех, `<uuid>_ofr.json` ошибка), `aep_6.pdf` → один, `aep_5.PDF` → один с ошибкой, `aep_8.pdf` → `aep_8_form4.json` как раньше (правило `<name>_*.json` покрывает `aep_8_form4`), неизвестное имя → `INVALID_DOCUMENT`; сквозной тест «put → эмулятор → poll (с settle) → merge» на `aep_3`. Проверка: `make check` зелёный. - [x] `src/scan_emulator/`: по входящему `<uuid>_<name>.<ext>` собрать из `SCAN_FIXTURES_DIR` все `<name>.json` и `<name>_*.json` (точное правило из Global Constraints), записать в `Output/<uuid>/` под именами `<uuid>.json` / `<uuid>_<suffix>.json` с `file_uid = <uuid>`; пары нет — прежний `INVALID_DOCUMENT`; `aep_3` не забирает `aep_30*`. Тесты (маркер `fixtures`, `tmp_path`): `aep_3.pdf` → два файла (`<uuid>.json` успех, `<uuid>_ofr.json` ошибка), `aep_6.pdf` → один, `aep_5.PDF` → один с ошибкой, `aep_8.pdf` → `aep_8_form4.json` как раньше (правило `<name>_*.json` покрывает `aep_8_form4`), неизвестное имя → `INVALID_DOCUMENT`; сквозной тест «put → эмулятор → poll (с settle) → merge» на `aep_3`. Проверка: `make check` зелёный.
### Task 5: [R5] Интеграционные тесты и документация ### Task 5: [R5] Интеграционные тесты и документация

View File

@ -210,3 +210,4 @@ warnings; `aep_5.PDF` → `INVALID_DOCUMENT` с `raw_s3_key`; объект `raw/
| 72 | 2026-10-04 | R-33 (design #72): `warnings` исхода дописываются в конец `metrics.warnings` исхода; итоговые `metrics` одинаковы в конверте и в файле результата (done/needs_check); пустой список не порождает `metrics` | одно место правды для предупреждений; файл результата и конверт не расходятся | нет | | 72 | 2026-10-04 | R-33 (design #72): `warnings` исхода дописываются в конец `metrics.warnings` исхода; итоговые `metrics` одинаковы в конверте и в файле результата (done/needs_check); пустой список не порождает `metrics` | одно место правды для предупреждений; файл результата и конверт не расходятся | нет |
| 73 | 2026-10-04 | T2.1, T2.2, T2.4 (design #73): поправки по ревью Task 2 — T2.1: после чтения набора каталог ответа листается повторно (Local и SMB), снимок отличается от настоявшегося → `None` (набор настаивается заново); T2.2: `SmbHotFolder` — ENOTDIR из листинга `Output/<job_id>` → `None`; T2.4: тест сброса состояния settle при ENOENT/SHARING_VIOLATION/ENOTDIR | файл, появившийся или пропавший за время чтения, иначе дал бы неполный или устаревший набор; поведение SMB = Local | один лишний листинг на каждый выданный набор | | 73 | 2026-10-04 | T2.1, T2.2, T2.4 (design #73): поправки по ревью Task 2 — T2.1: после чтения набора каталог ответа листается повторно (Local и SMB), снимок отличается от настоявшегося → `None` (набор настаивается заново); T2.2: `SmbHotFolder` — ENOTDIR из листинга `Output/<job_id>` → `None`; T2.4: тест сброса состояния settle при ENOENT/SHARING_VIOLATION/ENOTDIR | файл, появившийся или пропавший за время чтения, иначе дал бы неполный или устаревший набор; поведение SMB = Local | один лишний листинг на каждый выданный набор |
| 74 | 2026-10-04 | R-34 (design #74): интеграционный `tests/integration/test_roundtrip.py` минимально переведён на формат склейки `raw/` и успех `error = {"code": "", "message": ""}` (без новых сценариев — они в 7.4) | иначе он проверял бы старый формат `raw/`; полноценная переработка — задача 7.4 | не прогнан: в поднятом compose нет бакета `arm-ka` (`NoSuchBucket`), проверка — 7.4 после `make up` | | 74 | 2026-10-04 | R-34 (design #74): интеграционный `tests/integration/test_roundtrip.py` минимально переведён на формат склейки `raw/` и успех `error = {"code": "", "message": ""}` (без новых сценариев — они в 7.4) | иначе он проверял бы старый формат `raw/`; полноценная переработка — задача 7.4 | не прогнан: в поднятом compose нет бакета `arm-ka` (`NoSuchBucket`), проверка — 7.4 после `make up` |
| 75 | 2026-10-04 | R-35 (design #75): решения исполнителя Task 4 (7.3): эмулятор читает `SCAN_TMP_DIR` (по умолчанию `Tmp`) через `EmulatorSettings.tmp_dir`, как шлюз, не завися от настроек шлюза; набор собирается в `<root>/<SCAN_TMP_DIR>/.<uuid>.answer` и целиком переименовывается в `Output/<uuid>/`; остаток прерванной сборки удаляется, ответ с тем же uuid, уже лежащий в `Output`, заменяется (rmtree + rename); повреждённая фикстура набора даёт ответ-ошибку `INVALID_DOCUMENT` на своём месте, остальные файлы набора остаются; фикстуры с одним `suffix` (`a.JSON`/`a.json`) — берётся первая по имени; `aep_8.pdf` теперь даёт `<uuid>_form4.json`, а не `<uuid>.json` (прежнее правило `<name>*`) | spec scan-emulator: набор появляется атомарно, rename каталога возможен только в пределах одной ФС, служебная папка корня уже есть у шлюза (`Tmp`) | повторная публикация uuid на миг убирает каталог; потребители, ждавшие `aep_8` как `<uuid>.json`, должны брать набор целиком |

View File

@ -94,7 +94,7 @@ design №54–66. Контракт v1.0 не меняется; нормализ
нормализатор на реальных фикстурах `aep_6.json`, `aep_3.json` (не ошибка), `aep_3_ofr.json`, `aep_5.json` нормализатор на реальных фикстурах `aep_6.json`, `aep_3.json` (не ошибка), `aep_3_ofr.json`, `aep_5.json`
(`INVALID_DOCUMENT`), `aep_3.json` + `aep_3_ofr.json` в склейке под именами `<job_id>.json` / `<job_id>_ofr.json` → (`INVALID_DOCUMENT`), `aep_3.json` + `aep_3_ofr.json` в склейке под именами `<job_id>.json` / `<job_id>_ofr.json` →
`NOT_IMPLEMENTED` с одной строкой warnings `<job_id>_ofr.json: INVALID_DOCUMENT: …`, воркер с фейками; `make check` зелёный `NOT_IMPLEMENTED` с одной строкой warnings `<job_id>_ofr.json: INVALID_DOCUMENT: …`, воркер с фейками; `make check` зелёный
- [ ] 7.3 Эмулятор: набор файлов на задание — `<name>.json` и `<name>_*.json` в `Output/<uuid>/` как `<uuid>.json` / - [x] 7.3 Эмулятор: набор файлов на задание — `<name>.json` и `<name>_*.json` в `Output/<uuid>/` как `<uuid>.json` /
`<uuid>_<suffix>.json` с `file_uid = <uuid>`, атомарная запись каталога; проверка: unit-тесты (маркер `fixtures`) — `<uuid>_<suffix>.json` с `file_uid = <uuid>`, атомарная запись каталога; проверка: unit-тесты (маркер `fixtures`) —
`aep_3.pdf` → два файла, `aep_6.pdf` → один, `aep_5.PDF` → один с ошибкой, `aep_8.pdf` → `<uuid>_form4.json`, `aep_30*` не `aep_3.pdf` → два файла, `aep_6.pdf` → один, `aep_5.PDF` → один с ошибкой, `aep_8.pdf` → `<uuid>_form4.json`, `aep_30*` не
попадает в набор `aep_3`, неизвестное имя → `INVALID_DOCUMENT`; сквозной «put → эмулятор → poll (с settle) → склейка» на попадает в набор `aep_3`, неизвестное имя → `INVALID_DOCUMENT`; сквозной «put → эмулятор → poll (с settle) → склейка» на