Переименовать merge_results в result_payload, build_result вместо merge_documents

Функция не мержит, а заворачивает документы в единый конверт публикации:
одиночный документ идёт тем же путём и даёт files[] из одного элемента.
Потребителю не нужно знать, сколько документов распознал процессор.

Старые имена вводили в заблуждение на самом частом случае.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JvCSGmwgL8juhZdXr32qXM
This commit is contained in:
Andrey Epifancev 2026-09-14 14:10:31 +04:00
parent f79384e2b1
commit 6605e91838
2 changed files with 67 additions and 26 deletions

View File

@ -1,17 +1,21 @@
"""Склейка результатов PDF-процессора в один JSON. """Сборка результата PDF-процессора в единый формат публикации.
Процессор кладёт по одному json на каждый распознанный документ Процессор кладёт по одному json на каждый распознанный документ
({'file_uid': ..., 'error': ..., 'data_ocr': ...}). Здесь они собираются ({'file_uid': ..., 'error': ..., 'data_ocr': ...}). Здесь они заворачиваются
в единый результат вида: в общий конверт:
{'file_id': ..., 'legal_entity_id': ..., 'files': [<содержимое каждого>]} {'file_id': ..., 'legal_entity_id': ..., 'files': [<содержимое каждого>]}
Форма конверта не зависит от числа документов: одиночный документ проходит
тем же путём и даёт 'files' из одного элемента. Потребителю не нужно знать,
сколько документов распознал процессор, — он всегда читает 'files'.
Содержимое документов не обрабатывается и не валидируется — кладётся как есть. Содержимое документов не обрабатывается и не валидируется — кладётся как есть.
merge_documents() принимает уже прочитанные объекты и сама ничего не открывает. build_result() принимает уже прочитанные объекты и сама ничего не открывает.
Откуда они взялись — локальная ФС, SMB-шара, S3, http — решает вызывающий: Откуда они взялись — локальная ФС, SMB-шара, S3, http — решает вызывающий:
merge_documents([json.load(f) for f in handles], file_id, legal_entity_id) build_result([json.load(f) for f in handles], file_id, legal_entity_id)
Поэтому модуль не тянет ни config, ни S3, ни NATS, ни smb-клиент. Поэтому модуль не тянет ни config, ни S3, ни NATS, ни smb-клиент.
""" """
@ -22,15 +26,15 @@ import sys
from pathlib import Path from pathlib import Path
def merge_documents(documents, file_id, legal_entity_id) -> dict: def build_result(documents, file_id, legal_entity_id) -> dict:
"""Собирает распознанные документы в один результат. """Заворачивает распознанные документы в конверт результата.
documents — любой iterable уже разобранных json-объектов. documents — любой iterable уже разобранных json-объектов, от одного и больше.
Порядок элементов в 'files' сохраняется. Порядок элементов в 'files' сохраняется.
""" """
documents = list(documents) documents = list(documents)
if not documents: if not documents:
raise ValueError('нечего мержить: не передано ни одного документа') raise ValueError('нет ни одного документа: процессор ничего не распознал')
return { return {
'file_id': file_id, 'file_id': file_id,
@ -51,12 +55,12 @@ def _read_local(paths) -> list:
def main(argv=None) -> int: def main(argv=None) -> int:
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description='Склеить json-документы процессора в один результат.', description='Собрать json-документы процессора в единый результат.',
) )
parser.add_argument( parser.add_argument(
'documents', 'documents',
nargs='*', nargs='*',
help='пути к json-документам (порядок сохраняется в files[])', help='пути к json-документам, один и более (порядок сохраняется в files[])',
) )
parser.add_argument( parser.add_argument(
'-d', '--dir', '-d', '--dir',
@ -80,7 +84,7 @@ def main(argv=None) -> int:
try: try:
documents = _read_local(paths) documents = _read_local(paths)
result = merge_documents(documents, args.file_id, args.legal_entity_id) result = build_result(documents, args.file_id, args.legal_entity_id)
except (OSError, ValueError, json.JSONDecodeError) as e: except (OSError, ValueError, json.JSONDecodeError) as e:
print(f'ошибка: {e}', file=sys.stderr) print(f'ошибка: {e}', file=sys.stderr)
return 1 return 1
@ -88,7 +92,7 @@ def main(argv=None) -> int:
payload = json.dumps(result, ensure_ascii=False, indent=2) payload = json.dumps(result, ensure_ascii=False, indent=2)
if args.output: if args.output:
Path(args.output).write_text(payload, encoding='utf-8') Path(args.output).write_text(payload, encoding='utf-8')
print(f'записано {len(result["files"])} документов в {args.output}', file=sys.stderr) print(f'записано документов: {len(result["files"])} → {args.output}', file=sys.stderr)
else: else:
print(payload) print(payload)
return 0 return 0

View File

@ -5,7 +5,7 @@ from pathlib import Path
import pytest import pytest
from src.core.merge_results import merge_documents from src.core.result_payload import build_result
DOC_A = { DOC_A = {
'file_uid': 'uid-a', 'file_uid': 'uid-a',
@ -19,8 +19,19 @@ DOC_B = {
} }
def test_merges_two_documents_into_one_result(): def test_single_document_keeps_the_same_envelope():
result = merge_documents([DOC_A, DOC_B], file_id='file-1', legal_entity_id='le-1') # одиночный документ проходит через ту же функцию ради единого формата
result = build_result([DOC_A], file_id='file-1', legal_entity_id='le-1')
assert result == {
'file_id': 'file-1',
'legal_entity_id': 'le-1',
'files': [DOC_A],
}
def test_several_documents_go_into_one_envelope():
result = build_result([DOC_A, DOC_B], file_id='file-1', legal_entity_id='le-1')
assert result == { assert result == {
'file_id': 'file-1', 'file_id': 'file-1',
@ -29,14 +40,22 @@ def test_merges_two_documents_into_one_result():
} }
def test_envelope_shape_does_not_depend_on_count():
one = build_result([DOC_A], file_id='f', legal_entity_id='l')
many = build_result([DOC_A, DOC_B], file_id='f', legal_entity_id='l')
assert list(one) == list(many)
assert isinstance(one['files'], list)
def test_does_no_io_at_all(monkeypatch): def test_does_no_io_at_all(monkeypatch):
# функция не должна ничего открывать — чтение целиком на вызывающем # функция не должна ничего открывать — чтение целиком на вызывающем
def explode(*args, **kwargs): def explode(*args, **kwargs):
raise AssertionError('merge_documents не должна трогать файловую систему') raise AssertionError('build_result не должна трогать файловую систему')
monkeypatch.setattr('builtins.open', explode) monkeypatch.setattr('builtins.open', explode)
result = merge_documents([DOC_A], file_id='f', legal_entity_id='l') result = build_result([DOC_A], file_id='f', legal_entity_id='l')
assert result['files'] == [DOC_A] assert result['files'] == [DOC_A]
@ -45,39 +64,40 @@ def test_keeps_document_content_untouched():
# содержимое кладётся как есть: незнакомые поля и вложенность не теряются # содержимое кладётся как есть: незнакомые поля и вложенность не теряются
weird = {'file_uid': 'uid-c', 'unexpected': [1, {'deep': None}], 'кириллица': 'значение'} weird = {'file_uid': 'uid-c', 'unexpected': [1, {'deep': None}], 'кириллица': 'значение'}
result = merge_documents([weird], file_id='file-1', legal_entity_id='le-1') result = build_result([weird], file_id='file-1', legal_entity_id='le-1')
assert result['files'] == [weird] assert result['files'] == [weird]
def test_preserves_given_order(): def test_preserves_given_order():
result = merge_documents([DOC_B, DOC_A], file_id='f', legal_entity_id='l') result = build_result([DOC_B, DOC_A], file_id='f', legal_entity_id='l')
assert [doc['file_uid'] for doc in result['files']] == ['uid-b', 'uid-a'] assert [doc['file_uid'] for doc in result['files']] == ['uid-b', 'uid-a']
def test_accepts_any_iterable(): def test_accepts_any_iterable():
# вызывающий может отдавать генератор, например потоковое чтение с шары # вызывающий может отдавать генератор, например потоковое чтение с шары
result = merge_documents((d for d in (DOC_A, DOC_B)), file_id='f', legal_entity_id='l') result = build_result((d for d in (DOC_A, DOC_B)), file_id='f', legal_entity_id='l')
assert len(result['files']) == 2 assert len(result['files']) == 2
def test_rejects_empty_input(): def test_rejects_empty_input():
# ноль документов — процессор ничего не распознал; это ошибка, а не пустой успех
with pytest.raises(ValueError): with pytest.raises(ValueError):
merge_documents([], file_id='f', legal_entity_id='l') build_result([], file_id='f', legal_entity_id='l')
def test_cli_reads_local_files_and_writes_merged(tmp_path): def test_cli_reads_local_files_and_writes_result(tmp_path):
# CLI — один из вызывающих: локальное чтение живёт здесь, а не в merge_documents # CLI — один из вызывающих: локальное чтение живёт здесь, а не в build_result
a = tmp_path / 'doc_1.json' a = tmp_path / 'doc_1.json'
a.write_text(json.dumps(DOC_A, ensure_ascii=False), encoding='utf-8') a.write_text(json.dumps(DOC_A, ensure_ascii=False), encoding='utf-8')
b = tmp_path / 'doc_2.json' b = tmp_path / 'doc_2.json'
b.write_text(json.dumps(DOC_B, ensure_ascii=False), encoding='utf-8') b.write_text(json.dumps(DOC_B, ensure_ascii=False), encoding='utf-8')
out = tmp_path / 'merged.json' out = tmp_path / 'result.json'
completed = subprocess.run( completed = subprocess.run(
[sys.executable, '-m', 'src.core.merge_results', str(a), str(b), [sys.executable, '-m', 'src.core.result_payload', str(a), str(b),
'--file-id', 'file-1', '--legal-entity-id', 'le-1', '-o', str(out)], '--file-id', 'file-1', '--legal-entity-id', 'le-1', '-o', str(out)],
cwd=Path(__file__).resolve().parent.parent, cwd=Path(__file__).resolve().parent.parent,
capture_output=True, capture_output=True,
@ -90,3 +110,20 @@ def test_cli_reads_local_files_and_writes_merged(tmp_path):
'legal_entity_id': 'le-1', 'legal_entity_id': 'le-1',
'files': [DOC_A, DOC_B], 'files': [DOC_A, DOC_B],
} }
def test_cli_handles_single_document(tmp_path):
a = tmp_path / 'doc_1.json'
a.write_text(json.dumps(DOC_A, ensure_ascii=False), encoding='utf-8')
out = tmp_path / 'result.json'
completed = subprocess.run(
[sys.executable, '-m', 'src.core.result_payload', str(a),
'--file-id', 'file-1', '--legal-entity-id', 'le-1', '-o', str(out)],
cwd=Path(__file__).resolve().parent.parent,
capture_output=True,
text=True,
)
assert completed.returncode == 0, completed.stderr
assert json.loads(out.read_text(encoding='utf-8'))['files'] == [DOC_A]