- Добавлена 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
347 lines
18 KiB
Python
347 lines
18 KiB
Python
"""Эмулятор ИС Сканирование (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("Эмулятор остановлен")
|