"""Эмулятор ИС Сканирование (Content AI) для разработки и тестов (D-3, D-6, spec scan-emulator). Воспроизводит протокол горячих папок: для нового файла `.` во входящем каталоге через задержку кладёт каталог `//` с набором эталонных ответов из `SCAN_FIXTURES_DIR` по sha256 содержимого входящего файла. При совпадении хеша находит в фиксутрах файл с той же основой имени: `.json` -> `.json` и `_.json` -> `_.json`. При промахе по хешу использует набор по умолчанию (`SCAN_EMULATOR_DEFAULT`), если он задан и существует; иначе ответ-ошибка `INVALID_DOCUMENT`. Набор собирается во временном каталоге служебной папки (`SCAN_TMP_DIR`, та же ФС, что и ``) и появляется в `` одним 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 (36 символов) и расширение (1–16 буквоцифровых символов) _INBOX_NAME = re.compile( r"^(?P[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[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]]: """Фикстуры задания: `.json` и `_*.json` -> `(suffix, путь)`, `suffix` пуст для `.json`. Имя сравнивается буквально, без glob; расширение `.json` — без учёта регистра. Не префикс `*`: `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` — один ответ `.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): файлы пишутся в `/..answer`, затем каталог переименовывается в `//`. Служебный и исходящий каталоги лежат в одном корне (одна ФС), поэтому 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 обработанных файлов. Берёт новые `.` (не dot-файлы, не `*.done`, не `*.part`, не `*.tmp`, не каталоги), при наличии работы один раз за проход ждёт `delay` секунд. Для каждого файла: считает sha256 содержимого, ищет в индексе фикстур, при совпадении кладёт набор фикстур, при промахе — набор по умолчанию (если задан) или ошибку. Публикует набор ответов `//` (атомарно, через `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("Эмулятор: файлы не по шаблону . пропущены: {}", ", ".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("Эмулятор остановлен")