Andrey Epifancev bb56226177 feat(emulator): пара фикстур по sha256 содержимого, SCAN_EMULATOR_DEFAULT, входящий <uuid>.<ext> (D-3)
- Добавлена FixtureIndex с индексированием по sha256 содержимого файлов (не-JSON)
- build_index() строит индекс один раз, refresh() обновляет только при изменении списка файлов
- resolve_stem() находит основу имени по хешу, при промахе возвращает default или (None, "none")
- process_once() принимает параметр index и default, работает с файлами <uuid>.<ext>
- Пропускаются dot-файлы, *.part, *.tmp, *.done (Review Focus 5)
- Сообщение об ошибке: "{sha256[:12]}: нет пары по содержимому в фикстурах"
- EmulatorSettings.default = "" для SCAN_EMULATOR_DEFAULT
- Логирование источника пары: hash / default / none
- Все 909 тестов зелёные, включая 54 новых и переписанных тестов эмулятора

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-07 01:53:22 +04:00

347 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Эмулятор ИС Сканирование (Content AI) для разработки и тестов (D-3, D-6, spec scan-emulator).
Воспроизводит протокол горячих папок: для нового файла `<uuid>.<ext>` во входящем каталоге через задержку кладёт
каталог `<output>/<uuid>/` с набором эталонных ответов из `SCAN_FIXTURES_DIR` по sha256 содержимого входящего файла.
При совпадении хеша находит в фиксутрах файл с той же основой имени: `<stem>.json` -> `<uuid>.json` и
`<stem>_<suffix>.json` -> `<uuid>_<suffix>.json`. При промахе по хешу использует набор по умолчанию
(`SCAN_EMULATOR_DEFAULT`), если он задан и существует; иначе ответ-ошибка `INVALID_DOCUMENT`. Набор собирается во
временном каталоге служебной папки (`SCAN_TMP_DIR`, та же ФС, что и `<output>`) и появляется в `<output>` одним
rename каталога: шлюз не видит частичного набора. Эмулятор читает собственные переменные окружения
(`EmulatorSettings`), а не настройки шлюза: в его контейнере нет переменных MinIO/NATS. С каталогами работает через
`LocalHotFolder`, тем самым тестируя и его. Индекс хешей пересчитывается только при изменении списка файлов в
каталоге фикстур (Review Focus 5).
"""
import hashlib
import json
import math
import os
import re
import shutil
import threading
import time
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from dotenv import dotenv_values
from loguru import logger
from src.gateway.hotfolder import LocalHotFolder
__all__ = ["EmulatorSettings", "FixtureIndex", "build_index", "resolve_stem", "process_once", "run"]
# `<uuid>.<ext>`: канонический UUID (36 символов) и расширение (1–16 буквоцифровых символов)
_INBOX_NAME = re.compile(
r"^(?P<uuid>[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.(?P<ext>[0-9A-Za-z]{1,16})$"
)
_SKIP_SUFFIXES = (".done", ".part", ".tmp")
_ERROR_CODE = "INVALID_DOCUMENT"
def _read_float(env: Mapping[str, str], name: str, default: float, *, positive: bool) -> float:
raw = env.get(name)
if raw is None or raw.strip() == "":
return default
try:
value = float(raw)
except ValueError:
raise ValueError(f"{name}: ожидается число, получено {raw!r}") from None
if not math.isfinite(value) or value < 0 or (positive and value == 0):
raise ValueError(
f"{name}: ожидается {'положительное' if positive else 'неотрицательное'} число, получено {raw!r}"
)
return value
@dataclass(frozen=True)
class FixtureIndex:
"""sha256 содержимого не-JSON файлов каталога фикстур → основа имени (`aep_6.pdf` → `aep_6`)."""
names: tuple[str, ...]
by_hash: dict[str, str]
def refresh(self, fixtures: Path) -> "FixtureIndex":
"""Тот же объект, если список файлов каталога не изменился; иначе новый индекс."""
return self if _listing(fixtures) == self.names else build_index(fixtures)
def _listing(fixtures: Path) -> tuple[str, ...]:
try:
return tuple(sorted(p.name for p in fixtures.iterdir() if p.is_file() and not p.name.lower().endswith(".json")))
except OSError:
return ()
def build_index(fixtures: Path) -> FixtureIndex:
"""Построить индекс: для каждого файла не-.json считать sha256 → основа имени (без расширения)."""
names = _listing(fixtures)
by_hash: dict[str, str] = {}
for name in names:
try:
digest = hashlib.sha256((fixtures / name).read_bytes()).hexdigest()
except OSError:
continue
by_hash.setdefault(digest, Path(name).stem)
return FixtureIndex(names, by_hash)
def resolve_stem(data: bytes, index: FixtureIndex, default: str) -> tuple[str | None, str]:
"""Основа имени набора по содержимому; промах — `default`, если задан; иначе `(None, "none")`."""
stem = index.by_hash.get(hashlib.sha256(data).hexdigest())
if stem is not None:
return stem, "hash"
if default:
return default, "default"
return None, "none"
@dataclass(frozen=True)
class EmulatorSettings:
"""Настройки эмулятора из переменных окружения (имена совпадают с `.env.example`)."""
hotfolder_path: Path
input_dir: str = "Input"
output_dir: str = "Output"
tmp_dir: str = "Tmp"
fixtures_dir: Path = Path("../../fixtures/ocr")
delay: float = 2.0
poll_interval: float = 1.0
default: str = ""
@classmethod
def from_env(cls, environ: Mapping[str, str] | None = None) -> "EmulatorSettings":
"""Разбор окружения. Без аргумента: `os.environ` поверх файла `ENV_FILE` (по умолчанию `.env`, если он есть).
`SCAN_HOTFOLDER_PATH` обязателен (`ValueError`); `SCAN_INPUT_DIR`=Input, `SCAN_OUTPUT_DIR`=Output,
`SCAN_TMP_DIR`=Tmp (служебный каталог для сборки набора ответа), `SCAN_FIXTURES_DIR`=../../fixtures/ocr,
`SCAN_EMULATOR_DELAY`=2 (с, >= 0), `SCAN_EMULATOR_POLL_SECONDS`=1 (с, > 0), `SCAN_EMULATOR_DEFAULT`=""
(основа имени набора по умолчанию при промахе).
"""
if environ is None:
env_file = os.environ.get("ENV_FILE", ".env")
file_values = {k: v for k, v in dotenv_values(env_file).items() if v is not None}
env: Mapping[str, str] = {**file_values, **os.environ}
else:
env = environ
path = env.get("SCAN_HOTFOLDER_PATH", "").strip()
if not path:
raise ValueError("SCAN_HOTFOLDER_PATH не задан: укажите корень горячей папки (каталог с Input/ и Output/)")
return cls(
hotfolder_path=Path(path),
input_dir=env.get("SCAN_INPUT_DIR") or "Input",
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"),
delay=_read_float(env, "SCAN_EMULATOR_DELAY", 2.0, positive=False),
poll_interval=_read_float(env, "SCAN_EMULATOR_POLL_SECONDS", 1.0, positive=True),
default=(env.get("SCAN_EMULATOR_DEFAULT") or "").strip(),
)
_EMPTY_FIELD: dict[str, Any] = {"form_name": "", "confidence": 0}
def _error_answer(job_id: str, message: str) -> dict[str, Any]:
"""Ответ-ошибка в форме реальных ответов 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 _select_fixtures(fixtures: Path, stem: str) -> list[tuple[str, Path]]:
"""Фикстуры задания: `<stem>.json` и `<stem>_*.json` -> `(suffix, путь)`, `suffix` пуст для `<stem>.json`.
Имя сравнивается буквально, без glob; расширение `.json` — без учёта регистра. Не префикс `<stem>*`: `aep_30.json`
не входит в набор `aep_3`. Результат отсортирован по имени файла; при совпадении `suffix` берётся первый файл.
"""
if not stem:
return []
try:
names = sorted(p.name for p in fixtures.iterdir() if p.name.lower().endswith(".json") and p.is_file())
except OSError:
return []
selected: dict[str, Path] = {}
for fixture_name in names:
base = fixture_name[: -len(".json")]
if base == stem:
suffix = ""
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 _answer_file_name(job_id: str, suffix: str) -> str:
return f"{job_id}_{suffix}.json" if suffix else f"{job_id}.json"
def _build_answers(job_id: str, stem: str | None, fixtures: Path, *, miss_message: str) -> dict[str, dict[str, Any]]:
"""Набор ответов ИС на задание по основе имени фикстуры: `{имя файла ответа: ответ}`.
Каждый ответ — фикстура с подставленным `file_uid`; повреждённая фикстура даёт на своём месте ответ-ошибку; если
фикстур нет совсем или `stem is None` — один ответ `<uuid>.json` с `INVALID_DOCUMENT`.
"""
if stem is None:
return {_answer_file_name(job_id, ""): _error_answer(job_id, miss_message)}
selected = _select_fixtures(fixtures, stem)
if not selected:
return {_answer_file_name(job_id, ""): _error_answer(job_id, miss_message)}
answers: dict[str, dict[str, Any]] = {}
for suffix, fixture in selected:
try:
data = json.loads(fixture.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("корень не объект JSON")
except (OSError, ValueError) as exc: # json.JSONDecodeError и UnicodeDecodeError — подклассы ValueError
logger.error(
"Эмулятор: фикстура {} повреждена ({}); для stem={} отдаём {}", fixture, exc, stem, _ERROR_CODE
)
answers[_answer_file_name(job_id, suffix)] = _error_answer(
job_id, f"stem={stem}: эталонный ответ {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_json(path: Path, answer: dict[str, Any]) -> None:
path.write_text(json.dumps(answer, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
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:
staging.mkdir()
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:
shutil.rmtree(staging, ignore_errors=True)
raise
def process_once( # noqa: PLR0913
root: Path | str,
fixtures: Path | str,
delay: float,
*,
input_dir: str = "Input",
output_dir: str = "Output",
tmp_dir: str = "Tmp",
default: str = "",
index: FixtureIndex | None = None,
) -> list[str]:
"""Один проход по входящему каталогу; возвращает uuid обработанных файлов.
Берёт новые `<uuid>.<ext>` (не dot-файлы, не `*.done`, не `*.part`, не `*.tmp`, не каталоги), при наличии работы
один раз за проход ждёт `delay` секунд. Для каждого файла: считает sha256 содержимого, ищет в индексе фикстур,
при совпадении кладёт набор фикстур, при промахе — набор по умолчанию (если задан) или ошибку. Публикует набор
ответов `<output>/<uuid>/` (атомарно, через `tmp_dir`) и переименовывает входящий файл в `….done`. Имена не по
шаблону игнорируются (одно предупреждение за проход). Индекс обновляется (refresh) в начале прохода. Нет
входящего каталога — пустой результат.
"""
hotfolder = LocalHotFolder(root, input_dir, output_dir, tmp_dir)
fixtures_dir = Path(fixtures)
index = (index or build_index(fixtures_dir)).refresh(fixtures_dir)
try:
entries = sorted(hotfolder.input_dir.iterdir(), key=lambda p: p.name)
except FileNotFoundError:
return []
pending: list[tuple[Path, str]] = []
foreign: list[str] = []
for entry in entries:
if entry.name.startswith(".") or entry.name.lower().endswith(_SKIP_SUFFIXES) or not entry.is_file():
continue
match = _INBOX_NAME.match(entry.name)
if match is None:
foreign.append(entry.name)
continue
pending.append((entry, match["uuid"]))
if foreign:
logger.warning("Эмулятор: файлы не по шаблону <uuid>.<ext> пропущены: {}", ", ".join(foreign))
if not pending:
return []
if delay > 0:
time.sleep(delay)
processed: list[str] = []
for entry, job_id in pending:
try:
data = entry.read_bytes()
stem, source = resolve_stem(data, index, default)
digest = hashlib.sha256(data).hexdigest()
miss_message = f"{digest[:12]}: нет пары по содержимому в фикстурах"
_publish_answers(hotfolder, job_id, _build_answers(job_id, stem, fixtures_dir, miss_message=miss_message))
os.replace(entry, entry.with_name(entry.name + ".done"))
except FileNotFoundError:
logger.warning("Эмулятор: файл {} исчез во время обработки", entry.name)
continue
processed.append(job_id)
logger.info("Эмулятор: {} -> {} (пара: {})", entry.name, hotfolder.answer_dir(job_id), source)
return processed
def _count_fixtures(index: FixtureIndex) -> int:
return len(index.by_hash)
def run(settings: EmulatorSettings, stop: threading.Event, *, max_iterations: int | None = None) -> None:
"""Цикл эмулятора: `process_once` каждые `poll_interval` секунд, пока не взведён `stop` (или не выполнено
`max_iterations` проходов — для тестов). Сбой прохода логируется и не останавливает цикл. Индекс фикстур
строится один раз и обновляется на каждом проходе через refresh()."""
hotfolder = LocalHotFolder(settings.hotfolder_path, settings.input_dir, settings.output_dir, settings.tmp_dir)
logger.info(
"Эмулятор ИС Сканирование: корень {}, входящий каталог {}, исходящий каталог {}, задержка {} с, опрос {} с",
hotfolder.root,
hotfolder.input_dir,
hotfolder.output_dir,
settings.delay,
settings.poll_interval,
)
index: FixtureIndex | None = None
if settings.fixtures_dir.is_dir():
index = build_index(settings.fixtures_dir)
logger.info("Фикстуры: {}, пар: {}", settings.fixtures_dir, _count_fixtures(index))
else:
logger.warning(
"Каталог фикстур {} не найден: на все файлы будет отвечать {}", settings.fixtures_dir, _ERROR_CODE
)
iterations = 0
while not stop.is_set() and (max_iterations is None or iterations < max_iterations):
try:
process_once(
settings.hotfolder_path,
settings.fixtures_dir,
settings.delay,
input_dir=settings.input_dir,
output_dir=settings.output_dir,
tmp_dir=settings.tmp_dir,
default=settings.default,
index=index,
)
if index is not None:
index = index.refresh(settings.fixtures_dir)
except Exception: # noqa: BLE001 — эмулятор живёт, пока его не остановят
logger.exception("Эмулятор: сбой прохода по входящему каталогу")
iterations += 1
stop.wait(settings.poll_interval)
logger.info("Эмулятор остановлен")