feat(normalizer): справочники yaml с редакциями форм, загрузчик правил, SCAN_CLEANUP/SCAN_RULES_DIR (D-7)
Справочники нормализатора OCR: - api/rules/statement_lines.yaml: редакции форм ББ/ОФР (66n до 2024-12-31, fsbu4 с 2025-01-01) - api/rules/ocr_lookalikes.yaml: замены букв на цифры (из date_utils._LOOKALIKES) - api/rules/statement_labels.yaml: подстроки label → тип документа (BALANCE/INCOME) Модуль rules.py: - Edition: редакция с интервалом дат и таблицей кодов → наименований - OcrRules: все справочники + методы edition_for(), edition_by_codes(), known_codes(), editions_with() - load_rules(): загрузчик с валидацией (коды 4–5 цифр, двойники 1→1, файлы обязательны) Настройки: - SCAN_CLEANUP: удаление файлов из горячей папки (по умолчанию false) - SCAN_RULES_DIR: путь к справочникам (по умолчанию 'rules') TDD: 10 тестов (TestRealRules, TestEditionChoice, TestValidation, test_settings_defaults) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
533e5a2a4a
commit
393d65208f
16
api/rules/ocr_lookalikes.yaml
Normal file
16
api/rules/ocr_lookalikes.yaml
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# Буквы и знаки, которые OCR ставит вместо цифр (§11.1 п. 1). Замена — только вплотную к цифре, в несколько проходов.
|
||||||
|
digits:
|
||||||
|
"О": "0"
|
||||||
|
"о": "0"
|
||||||
|
"O": "0"
|
||||||
|
"o": "0"
|
||||||
|
"З": "3"
|
||||||
|
"з": "3"
|
||||||
|
"l": "1"
|
||||||
|
"I": "1"
|
||||||
|
"|": "1"
|
||||||
|
"і": "1"
|
||||||
|
"б": "6"
|
||||||
|
"S": "5"
|
||||||
|
"s": "5"
|
||||||
|
"Ч": "4"
|
||||||
6
api/rules/statement_labels.yaml
Normal file
6
api/rules/statement_labels.yaml
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
# Нормализованные (нижний регистр, не-буквенно-цифровое → пробел) подстроки label таблицы → тип документа (§11 / D-6).
|
||||||
|
labels:
|
||||||
|
"бухгалтерский баланс": BALANCE
|
||||||
|
"отчет о финансовых результатах": INCOME
|
||||||
|
"отчёт о финансовых результатах": INCOME
|
||||||
|
"отчет о прибылях и убытках": INCOME
|
||||||
76
api/rules/statement_lines.yaml
Normal file
76
api/rules/statement_lines.yaml
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Справочник «код строки → наименование показателя» форм ББ/ОФР по редакциям (§11.1 п. 6, D-7).
|
||||||
|
# Редакция выбирается по дате отчёта документа: valid_until / valid_from — включительно.
|
||||||
|
# 66n — Приказ Минфина № 66н (отчётность до 2024 года включительно), источник — codes_mapping старого ядра DKB_ARMKA
|
||||||
|
# (проверен на стенде ИС Сканирование). fsbu4 — ФСБУ 4/2023 (отчётность за 2025 год и позже): заполняет владелец
|
||||||
|
# по Приложениям 3–4 ФСБУ 4/2023 (черновой перечень кодов — шаблоны fixtures/statements-xlsx/templates/*после 01.01.2025*.xlsx);
|
||||||
|
# пока пуста, документы 2025 года получают имена из OCR и предупреждение.
|
||||||
|
editions:
|
||||||
|
- id: 66n
|
||||||
|
valid_until: 2024-12-31
|
||||||
|
lines:
|
||||||
|
"1105": "Гудвил"
|
||||||
|
"1100": "Итого по разделу I"
|
||||||
|
"1110": "Нематериальные активы"
|
||||||
|
"1120": "Результаты исследований и разработок"
|
||||||
|
"1130": "Нематериальные поисковые активы"
|
||||||
|
"1140": "Материальные поисковые активы"
|
||||||
|
"1150": "Основные средства"
|
||||||
|
"1160": "Инвестиционная недвижимость"
|
||||||
|
"1170": "Финансовые вложения"
|
||||||
|
"1180": "Отложенные налоговые активы"
|
||||||
|
"1190": "Прочие внеоборотные активы"
|
||||||
|
"1200": "Итого по разделу II"
|
||||||
|
"1210": "Запасы"
|
||||||
|
"1215": "Долгосрочные активы к продаже"
|
||||||
|
"1220": "Налог на добавленную стоимость по приобретённым ценностями"
|
||||||
|
"1230": "Дебиторская задолженность"
|
||||||
|
"1240": "Финансовые вложения (за исключением денежных эквивалентов)"
|
||||||
|
"1250": "Денежные средства и денежные эквиваленты"
|
||||||
|
"1260": "Прочие оборотные активы"
|
||||||
|
"1600": "БАЛАНС"
|
||||||
|
"1300": "Итого по разделу III"
|
||||||
|
"1310": "Уставный капитал"
|
||||||
|
"1320": "Собственные акции, выкупленные у акционеров"
|
||||||
|
"1330": "Целевые средства"
|
||||||
|
"1340": "Переоценка внеоборотных активов"
|
||||||
|
"1350": "Добавочный капитал (без переоценки)"
|
||||||
|
"1360": "Резервный капитал"
|
||||||
|
"1370": "Нераспределенная прибыль (непокрытый убыток)"
|
||||||
|
"1400": "Итого по разделу IV"
|
||||||
|
"1410": "Заемные средства"
|
||||||
|
"1420": "Отложенные налоговые обязательства"
|
||||||
|
"1430": "Оценочные обязательства"
|
||||||
|
"1450": "Прочие обязательства"
|
||||||
|
"1500": "Итого по разделу V"
|
||||||
|
"1510": "Заемные средства"
|
||||||
|
"1520": "Кредиторская задолженность"
|
||||||
|
"1530": "Доходы будущих периодов"
|
||||||
|
"1540": "Оценочные обязательства"
|
||||||
|
"1550": "Прочие обязательства"
|
||||||
|
"1700": "БАЛАНС"
|
||||||
|
"2110": "Выручка"
|
||||||
|
"2120": "Себестоимость продаж"
|
||||||
|
"2100": "Валовая прибыль (убыток)"
|
||||||
|
"2210": "Коммерческие расходы"
|
||||||
|
"2220": "Управленческие расходы"
|
||||||
|
"2200": "Прибыль (убыток) от продаж"
|
||||||
|
"2310": "Доходы от участия в других организациях"
|
||||||
|
"2320": "Проценты к получению"
|
||||||
|
"2330": "Проценты к уплате"
|
||||||
|
"2340": "Прочие доходы"
|
||||||
|
"2350": "Прочие расходы"
|
||||||
|
"2300": "Прибыль (убыток) до налогообложения"
|
||||||
|
"2410": "Налог на прибыль"
|
||||||
|
"2411": "в т.ч. текущий налог на прибыль"
|
||||||
|
"2412": "отложенный налог на прибыль"
|
||||||
|
"2460": "Прочее"
|
||||||
|
"2400": "Чистая прибыль (убыток)"
|
||||||
|
"2510": "Результат от переоценки внеоборотных активов, не включаемый в чистую прибыль (убыток) периода"
|
||||||
|
"2520": "Результат от прочих операций, не включаемый в чистую прибыль (убыток) периода"
|
||||||
|
"2530": "Налог на прибыль от операций, результат которых не включается в чистую прибыль (убыток) периода"
|
||||||
|
"2500": "Совокупный финансовый результат периода"
|
||||||
|
"2900": "Базовая прибыль (убыток) на акцию"
|
||||||
|
"2910": "Разводненная прибыль (убыток) на акцию"
|
||||||
|
- id: fsbu4
|
||||||
|
valid_from: 2025-01-01
|
||||||
|
lines: {}
|
||||||
@ -74,6 +74,10 @@ class Settings(BaseSettings):
|
|||||||
SCAN_SETTLE_SECONDS: NonNegativeFloat = 2
|
SCAN_SETTLE_SECONDS: NonNegativeFloat = 2
|
||||||
SCAN_TIMEOUT_SECONDS: PositiveFloat = 300 # потолок ожидания ответа ИС (дополнительно к reply_deadline запроса)
|
SCAN_TIMEOUT_SECONDS: PositiveFloat = 300 # потолок ожидания ответа ИС (дополнительно к reply_deadline запроса)
|
||||||
SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий
|
SCAN_MAX_INFLIGHT: PositiveInt = 4 # одновременно обрабатываемых заданий
|
||||||
|
# Очистка горячей папки после прочтения набора ответа (D-2): любое удаление, включая .part в Tmp, только при true.
|
||||||
|
# На SMB-шаре контура права на удаление не подтверждены — по умолчанию выключено
|
||||||
|
SCAN_CLEANUP: bool = False
|
||||||
|
SCAN_RULES_DIR: str = "rules" # справочники нормализатора (yaml), путь от текущего каталога (api/)
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
|
|||||||
139
api/src/normalizer/rules.py
Normal file
139
api/src/normalizer/rules.py
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
"""Справочники нормализатора OCR (D-7, §11.1 п. 1, 6): yaml в `SCAN_RULES_DIR`, загрузка один раз при старте.
|
||||||
|
|
||||||
|
`statement_lines.yaml` — редакции форм (`66n` до 2024-12-31, `fsbu4` с 2025-01-01) с таблицей код → наименование;
|
||||||
|
`ocr_lookalikes.yaml` — двойники букв и цифр; `statement_labels.yaml` — подстроки `label` таблицы → тип документа.
|
||||||
|
Битый файл — `ValueError` с именем файла (сервис не стартует).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Iterable
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
|
||||||
|
|
||||||
|
__all__ = ["Edition", "OcrRules", "load_rules"]
|
||||||
|
|
||||||
|
LINES_FILE = "statement_lines.yaml"
|
||||||
|
LOOKALIKES_FILE = "ocr_lookalikes.yaml"
|
||||||
|
LABELS_FILE = "statement_labels.yaml"
|
||||||
|
DocumentKind = Literal["BALANCE", "INCOME"]
|
||||||
|
|
||||||
|
|
||||||
|
class Edition(BaseModel):
|
||||||
|
"""Редакция форм: интервал дат отчёта (включительно, открытый край — `None`) и таблица код → наименование."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True, extra="forbid")
|
||||||
|
|
||||||
|
id: str = Field(min_length=1)
|
||||||
|
valid_from: date | None = None
|
||||||
|
valid_until: date | None = None
|
||||||
|
lines: dict[str, str] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
@field_validator("lines")
|
||||||
|
@classmethod
|
||||||
|
def _check_lines(cls, lines: dict[str, str]) -> dict[str, str]:
|
||||||
|
for code, name in lines.items():
|
||||||
|
if not (code.isdigit() and 4 <= len(code) <= 5): # noqa: PLR2004 — коды строк форм: 4–5 цифр
|
||||||
|
raise ValueError(f"код строки {code!r}: ожидаются 4–5 цифр")
|
||||||
|
if not name.strip():
|
||||||
|
raise ValueError(f"код строки {code!r}: пустое наименование")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def covers(self, report_date: date) -> bool:
|
||||||
|
return (self.valid_from is None or self.valid_from <= report_date) and (
|
||||||
|
self.valid_until is None or report_date <= self.valid_until
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _LinesFile(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
editions: list[Edition] = Field(min_length=1)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _unique_ids(self) -> "_LinesFile":
|
||||||
|
ids = [edition.id for edition in self.editions]
|
||||||
|
if len(ids) != len(set(ids)):
|
||||||
|
raise ValueError(f"id редакций повторяются: {ids}")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class _LookalikesFile(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
digits: dict[str, str]
|
||||||
|
|
||||||
|
@field_validator("digits")
|
||||||
|
@classmethod
|
||||||
|
def _single_chars(cls, digits: dict[str, str]) -> dict[str, str]:
|
||||||
|
for char, digit in digits.items():
|
||||||
|
if len(char) != 1 or len(digit) != 1 or not digit.isdigit():
|
||||||
|
raise ValueError(f"двойник {char!r} -> {digit!r}: ожидается один символ и одна цифра")
|
||||||
|
return digits
|
||||||
|
|
||||||
|
|
||||||
|
class _LabelsFile(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
labels: dict[str, DocumentKind]
|
||||||
|
|
||||||
|
|
||||||
|
class OcrRules(BaseModel):
|
||||||
|
"""Все справочники нормализатора; передаётся в конвейер параметром."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(frozen=True)
|
||||||
|
|
||||||
|
editions: list[Edition]
|
||||||
|
lookalikes: dict[str, str]
|
||||||
|
labels: dict[str, DocumentKind]
|
||||||
|
|
||||||
|
def edition_for(self, report_date: date | None) -> Edition | None:
|
||||||
|
"""Редакция по дате отчёта (первая, чей интервал покрывает дату); `None` без даты или вне интервалов."""
|
||||||
|
if report_date is None:
|
||||||
|
return None
|
||||||
|
return next((edition for edition in self.editions if edition.covers(report_date)), None)
|
||||||
|
|
||||||
|
def edition_by_codes(self, codes: Iterable[str]) -> Edition | None:
|
||||||
|
"""Редакция с наибольшим числом известных кодов; при равенстве — первая; `None`, если совпадений нет."""
|
||||||
|
wanted = set(codes)
|
||||||
|
best: Edition | None = None
|
||||||
|
best_hits = 0
|
||||||
|
for edition in self.editions:
|
||||||
|
hits = len(wanted & edition.lines.keys())
|
||||||
|
if hits > best_hits:
|
||||||
|
best, best_hits = edition, hits
|
||||||
|
return best
|
||||||
|
|
||||||
|
def known_codes(self) -> frozenset[str]:
|
||||||
|
return frozenset(code for edition in self.editions for code in edition.lines)
|
||||||
|
|
||||||
|
def editions_with(self, code: str) -> list[str]:
|
||||||
|
return [edition.id for edition in self.editions if code in edition.lines]
|
||||||
|
|
||||||
|
|
||||||
|
def _read_yaml(directory: Path, name: str) -> object:
|
||||||
|
path = directory / name
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
raise ValueError(f"справочник {name} не прочитан ({path}): {exc}") from exc
|
||||||
|
try:
|
||||||
|
return yaml.safe_load(text)
|
||||||
|
except yaml.YAMLError as exc:
|
||||||
|
raise ValueError(f"справочник {name} не разобран как YAML: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def load_rules(directory: Path | str) -> OcrRules:
|
||||||
|
"""Загрузить три справочника из каталога; `ValueError` с именем файла при любой ошибке."""
|
||||||
|
base = Path(directory)
|
||||||
|
loaded: dict[str, BaseModel] = {}
|
||||||
|
for name, model in ((LINES_FILE, _LinesFile), (LOOKALIKES_FILE, _LookalikesFile), (LABELS_FILE, _LabelsFile)):
|
||||||
|
try:
|
||||||
|
loaded[name] = model.model_validate(_read_yaml(base, name))
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise ValueError(f"справочник {name}: {exc}") from exc
|
||||||
|
lines = loaded[LINES_FILE]
|
||||||
|
lookalikes = loaded[LOOKALIKES_FILE]
|
||||||
|
labels = loaded[LABELS_FILE]
|
||||||
|
assert isinstance(lines, _LinesFile) and isinstance(lookalikes, _LookalikesFile) # noqa: S101 — сужение типов
|
||||||
|
assert isinstance(labels, _LabelsFile) # noqa: S101
|
||||||
|
return OcrRules(editions=lines.editions, lookalikes=lookalikes.digits, labels=labels.labels)
|
||||||
0
api/tests/unit/normalizer/__init__.py
Normal file
0
api/tests/unit/normalizer/__init__.py
Normal file
115
api/tests/unit/normalizer/test_rules.py
Normal file
115
api/tests/unit/normalizer/test_rules.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"""Справочники нормализатора (spec «Справочники нормализации — данные с редакциями форм», D-7)."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from src.core.config import get_settings
|
||||||
|
from src.normalizer.rules import Edition, OcrRules, load_rules
|
||||||
|
|
||||||
|
API_DIR = Path(__file__).resolve().parents[3]
|
||||||
|
RULES_DIR = API_DIR / "rules"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_rules(tmp_path: Path, lines: dict | None = None) -> Path:
|
||||||
|
(tmp_path / "statement_lines.yaml").write_text(
|
||||||
|
yaml.safe_dump(
|
||||||
|
lines
|
||||||
|
or {
|
||||||
|
"editions": [
|
||||||
|
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "Основные средства"}},
|
||||||
|
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
allow_unicode=True,
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "0", "l": "1"}}), encoding="utf-8")
|
||||||
|
(tmp_path / "statement_labels.yaml").write_text(
|
||||||
|
yaml.safe_dump({"labels": {"бухгалтерский баланс": "BALANCE"}}, allow_unicode=True), encoding="utf-8"
|
||||||
|
)
|
||||||
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
|
class TestRealRules:
|
||||||
|
def test_loads_repository_rules(self) -> None:
|
||||||
|
rules = load_rules(RULES_DIR)
|
||||||
|
ids = [edition.id for edition in rules.editions]
|
||||||
|
assert ids == ["66n", "fsbu4"]
|
||||||
|
assert rules.editions[0].lines["1150"] == "Основные средства"
|
||||||
|
assert rules.editions[0].lines["1120"] == "Результаты исследований и разработок"
|
||||||
|
assert rules.lookalikes["О"] == "0"
|
||||||
|
assert rules.labels["бухгалтерский баланс"] == "BALANCE"
|
||||||
|
assert rules.labels["отчет о финансовых результатах"] == "INCOME"
|
||||||
|
|
||||||
|
def test_codes_are_four_or_five_digits_without_duplicates(self) -> None:
|
||||||
|
rules = load_rules(RULES_DIR)
|
||||||
|
for edition in rules.editions:
|
||||||
|
codes = list(edition.lines)
|
||||||
|
assert all(code.isdigit() and 4 <= len(code) <= 5 for code in codes), edition.id
|
||||||
|
assert len(codes) == len(set(codes)), edition.id
|
||||||
|
assert all(name.strip() for name in edition.lines.values()), edition.id
|
||||||
|
|
||||||
|
def test_fsbu4_edition_exists_even_if_empty(self) -> None:
|
||||||
|
rules = load_rules(RULES_DIR)
|
||||||
|
fsbu4 = next(edition for edition in rules.editions if edition.id == "fsbu4")
|
||||||
|
assert fsbu4.valid_from == date(2025, 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
class TestEditionChoice:
|
||||||
|
def test_by_report_date(self, tmp_path: Path) -> None:
|
||||||
|
rules = load_rules(_write_rules(tmp_path))
|
||||||
|
assert rules.edition_for(date(2024, 12, 31)).id == "66n"
|
||||||
|
assert rules.edition_for(date(2025, 6, 30)).id == "fsbu4"
|
||||||
|
assert rules.edition_for(None) is None
|
||||||
|
|
||||||
|
def test_by_codes_prefers_more_hits(self, tmp_path: Path) -> None:
|
||||||
|
rules = load_rules(
|
||||||
|
_write_rules(
|
||||||
|
tmp_path,
|
||||||
|
{
|
||||||
|
"editions": [
|
||||||
|
{"id": "66n", "valid_until": "2024-12-31", "lines": {"1150": "ОС", "1110": "НМА"}},
|
||||||
|
{"id": "fsbu4", "valid_from": "2025-01-01", "lines": {"1150": "ОС", "1231": "ДЗ долгосрочная"}},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert rules.edition_by_codes(["1150", "1231"]).id == "fsbu4"
|
||||||
|
assert rules.edition_by_codes(["1150"]).id == "66n" # равенство — первая
|
||||||
|
assert rules.edition_by_codes(["9999"]) is None
|
||||||
|
|
||||||
|
def test_known_codes_and_editions_with(self, tmp_path: Path) -> None:
|
||||||
|
rules = load_rules(_write_rules(tmp_path))
|
||||||
|
assert rules.known_codes() == frozenset({"1150"})
|
||||||
|
assert rules.editions_with("1150") == ["66n"]
|
||||||
|
assert rules.editions_with("1110") == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidation:
|
||||||
|
def test_bad_code_fails_with_file_name(self, tmp_path: Path) -> None:
|
||||||
|
_write_rules(tmp_path, {"editions": [{"id": "66n", "lines": {"11A0": "x"}}]})
|
||||||
|
with pytest.raises(ValueError, match="statement_lines.yaml"):
|
||||||
|
load_rules(tmp_path)
|
||||||
|
|
||||||
|
def test_missing_file_fails(self, tmp_path: Path) -> None:
|
||||||
|
_write_rules(tmp_path)
|
||||||
|
(tmp_path / "ocr_lookalikes.yaml").unlink()
|
||||||
|
with pytest.raises(ValueError, match="ocr_lookalikes.yaml"):
|
||||||
|
load_rules(tmp_path)
|
||||||
|
|
||||||
|
def test_lookalike_must_map_to_single_digit(self, tmp_path: Path) -> None:
|
||||||
|
_write_rules(tmp_path)
|
||||||
|
(tmp_path / "ocr_lookalikes.yaml").write_text(yaml.safe_dump({"digits": {"О": "00"}}), encoding="utf-8")
|
||||||
|
with pytest.raises(ValueError, match="ocr_lookalikes.yaml"):
|
||||||
|
load_rules(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_settings_defaults() -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
assert settings.SCAN_CLEANUP is False
|
||||||
|
assert settings.SCAN_RULES_DIR == "rules"
|
||||||
|
assert isinstance(Edition, type) and isinstance(OcrRules, type)
|
||||||
Loading…
x
Reference in New Issue
Block a user