fix: locator и line_refs предупреждений и locator документов очищаются от суррогатов (T4.I-1, #46)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 20:14:34 +04:00
parent ae2922eb30
commit edd3d03f25
2 changed files with 45 additions and 4 deletions

View File

@ -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})

View File

@ -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 и без файла в горячей папке; сам конверт публикуется."""