From edd3d03f25bb7d6d5ea9a0f50c19a01d34f7f48e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:14:34 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20locator=20=D0=B8=20line=5Frefs=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B5=D0=B4=D1=83=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B8=20locator=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D1=89=D0=B0=D1=8E=D1=82=D1=81=D1=8F=20=D0=BE=D1=82=20=D1=81?= =?UTF-8?q?=D1=83=D1=80=D1=80=D0=BE=D0=B3=D0=B0=D1=82=D0=BE=D0=B2=20(T4.I-?= =?UTF-8?q?1,=20#46)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- api/src/core/worker.py | 19 +++++++++++++++---- api/tests/unit/test_worker.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/api/src/core/worker.py b/api/src/core/worker.py index dbd2c90..b5e154b 100644 --- a/api/src/core/worker.py +++ b/api/src/core/worker.py @@ -682,6 +682,8 @@ def build_result( # noqa: PLR0913 - идентификаторы + исход + if outcome.statements_version is not None else service ) + # locator документа содержит имя файла ответа (недоверенное): суррогаты не должны ломать запись файла (T10.2) + documents = [d.model_copy(update={"locator": clean_text(d.locator, MAX_WARNING_CHARS)}) for d in outcome.documents] return RecognitionResult.model_validate( { "schema_version": CONTRACT_VERSION, @@ -690,7 +692,7 @@ def build_result( # noqa: PLR0913 - идентификаторы + исход + "service": result_service, "status": outcome.status, "raw_s3_key": raw_s3_key, - "documents": list(outcome.documents), + "documents": documents, **({"metrics": metrics} if metrics is not None else {}), } ) @@ -706,6 +708,17 @@ def _limit_warnings(warnings: list[FileWarning]) -> list[FileWarning]: return [warnings[i] for i in keep] +def _clean_warning(warning: FileWarning) -> FileWarning: + """Предупреждение, безопасное для конверта и файла: `message`, `locator` (содержит имя файла ответа — недоверенное, + на Linux с одиночными суррогатами) и `line_refs` проходят `clean_text` (T10.2).""" + update: dict[str, Any] = {"message": clean_text(warning.message, MAX_WARNING_CHARS)} + if warning.locator is not None: + update["locator"] = clean_text(warning.locator, MAX_WARNING_CHARS) + if warning.line_refs is not None: + update["line_refs"] = [clean_text(ref, MAX_WARNING_CHARS) for ref in warning.line_refs] + return warning.model_copy(update=update) + + def _outcome_metrics(outcome: NormalizeOutcome) -> Metrics | None: """`metrics` конверта и файла результата: метрики исхода + `warnings` исхода (R-25, P5-10). @@ -716,9 +729,7 @@ def _outcome_metrics(outcome: NormalizeOutcome) -> Metrics | None: warnings = [*(metrics.warnings or [] if metrics is not None else []), *outcome.warnings] if not warnings: return metrics - cleaned = [ - w.model_copy(update={"message": clean_text(w.message, MAX_WARNING_CHARS)}) for w in _limit_warnings(warnings) - ] + cleaned = [_clean_warning(w) for w in _limit_warnings(warnings)] base = metrics.model_dump(exclude_none=True, exclude={"warnings"}) if metrics is not None else {} return Metrics.model_validate({**base, "warnings": cleaned}) diff --git a/api/tests/unit/test_worker.py b/api/tests/unit/test_worker.py index d1272fd..8d0b452 100644 --- a/api/tests/unit/test_worker.py +++ b/api/tests/unit/test_worker.py @@ -897,6 +897,36 @@ async def test_lone_surrogate_in_is_error_message_does_not_break_publishing(make assert _raw(env, job_id)["files"][0]["response"]["error"]["message"] == "bad \ud800 tail" +async def test_lone_surrogate_in_answer_file_name_does_not_break_publishing(make_env: EnvFactory) -> None: + """T4.I-1: имя файла ответа с одиночным суррогатом (`locator` предупреждения) не ломает конверт.""" + env = await make_env() + request = env.request() + job_id = request["job_id"] + env.hotfolder.poll_script = [ + [AnswerFile(f"{job_id}.json", _json(SUCCESS_ANSWER)), AnswerFile(f"{job_id}_\udcff.json", b"{not json")] + ] + body = await env.run(request) # публикация не упала: конверт получен и проверен схемой + [warning] = body["metrics"]["warnings"] + assert warning["locator"] == f"file:{job_id}_?.json" + + +async def test_lone_surrogate_in_answer_file_name_does_not_break_result_file(make_env: EnvFactory) -> None: + """T4.I-1: то же для файла результата: `locator` документа и предупреждений с суррогатом в имени файла.""" + env = await make_env(normalizer=OcrNormalizer(version="abc1234")) + request = env.request() + job_id = request["job_id"] + name = f"{job_id}_\udcff.json" + env.hotfolder.poll_script = [ + [AnswerFile(name, _json(SUCCESS_ANSWER)), AnswerFile(f"{job_id}_x.json", b"{not json")] + ] + body = await env.run(request) + assert body["status"] == "needs_check" + result = json.loads(env.storage.objects[(BUCKET, body["result_s3"]["key"])]) + RESULT_VALIDATOR.validate(result) + assert result["documents"][0]["locator"] == f"file:{job_id}_?.json;table:0" + assert body["metrics"]["warnings"] == result["metrics"]["warnings"] + + async def test_lone_surrogate_in_original_name_is_schema_mismatch(make_env: EnvFactory) -> None: """Одиночный суррогат в `original_name` схема запроса не пропускает (pydantic `string_unicode`): SCHEMA_MISMATCH без ack и без файла в горячей папке; сам конверт публикуется."""