diff --git a/AGENTS.md b/AGENTS.md index d7140e1..6e7d0e0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -296,7 +296,7 @@ route is protected by default rather than by remembering a dependency): | `POST /api/v1/anonymize/stream` | Same inputs, streams NDJSON `{"event":"progress"…}` lines then `{"event":"result"…}`. Inputs are parsed *before* streaming starts, so malformed requests still fail with normal HTTP errors. A client disconnect cancels the pipeline. | | `POST /api/v1/anonymize/{request_id}/extend` | Keep a cached result for another TTL, never past the hard lifetime ceiling. 410 when it is already gone. Backs the review view's countdown + **Verlängern**. | | `DELETE /api/v1/anonymize/{request_id}` | Forget one cached detection now. Always 204 (an unknown id must not be distinguishable). Called by the UI when a document is closed, reset, or the tab unloads. | -| `POST /api/v1/export/pdf` | Redacted-PDF export. The client **re-sends the original file** (nothing is stored); `request_id` + matching hash avoids re-running OCR/detection. | +| `POST /api/v1/export/pdf` | Redacted-PDF export. The client **re-sends the original file** (nothing is stored); `request_id` + matching hash avoids re-running OCR/detection. A refusal answers `{detail, code, forceable, items}`; `force_export=true` re-runs it and is honoured for `forceable` findings only. | | `POST /api/v1/export/pdf/pages` | Renders pages as PNGs for the area-redaction editor, with embedded-image boxes as one-click suggestions. | | `GET /api/v1/status` | Configured detectors + OCR engine, endpoint **hosts** and their locality, limits. Never returns paths, keys, or full URLs. | | `GET /health/live`, `GET /health/ready` | Liveness/readiness. | @@ -324,7 +324,7 @@ extensions `.txt/.docx/.pdf`. `Cache-Control: no-store` on content routes | `utils/policy.py`, `utils/transformation.py` | Default policy, labels, pure transformations. | | `utils/leakage.py` | Output validation + `compute_status`. | | `utils/cache.py` | `request_cache` (TTL, bounded, in-memory). | -| `utils/pdf_export.py` | Native-PDF true redaction, rasterized fallback, scanned-PDF reconstruction, page rendering. **Fails closed**: an export that cannot be verified is refused. | +| `utils/pdf_export.py` | Native-PDF true redaction, rasterized fallback, scanned-PDF reconstruction, page rendering. **Fails closed**: an export that cannot be verified is refused. The one exception is a residual the anonymized *text* has too (`expected_text`), which is refused as `forceable` and exported only after the reviewer confirms it. | | `utils/notices.py` | Stable codes + English text for every non-fatal message (the translation contract). | | `utils/policy.py` | Default policy + the replacement placeholders of every output language. | | `utils/auth.py` | Session + login-state tokens for the OIDC gate (HS256, PKCE helpers). No session store: the signed cookie *is* the session. | @@ -757,7 +757,9 @@ npm run test:e2e # when you touched the API or the UI flow exactly this; don't skip it because "it's only a label". - **Silent degradation.** Never catch a `DetectorError` and continue with fewer detectors, and never let a failed export produce an unverified PDF. - Failing loudly is the feature. + Failing loudly is the feature. `force_export` is not a hole in that: it + applies to one classified finding, the reviewer is told what stays visible, + and the PDF panel keeps saying so afterwards. - **The cache is not persistence.** A 410 is normal; the frontend re-posts the source text. Don't "fix" it by extending the TTL indefinitely or writing to disk. diff --git a/CHANGELOG.md b/CHANGELOG.md index 785dac4..7ae138a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,15 @@ documentation, and CI work are left out. over the placeholders of a scanned document's rebuilt PDF, so it looks like a native redacted PDF — see [Exporting](docs/user-guide/export.md#redacted-pdf). +### Changed + +- A PDF export refused because a redacted text also occurs outside the redacted + passages — a name you kept, or an occurrence no detector found — now names + those passages and offers **Trotzdem exportieren** instead of failing, since + the anonymized text download shows them too. Every other verification failure + is still refused outright. See + [Exporting](docs/user-guide/export.md#when-the-check-finds-text-you-kept). + ## [0.3.0] — 2026-08-20 ### Added diff --git a/backend/src/routers/v1/endpoints/export.py b/backend/src/routers/v1/endpoints/export.py index ffe2652..0bb34ce 100644 --- a/backend/src/routers/v1/endpoints/export.py +++ b/backend/src/routers/v1/endpoints/export.py @@ -7,12 +7,16 @@ Native PDFs are rasterized with exact char-box blackout; scanned PDFs are rebuilt from the anonymized text at the OCR layout positions. Both paths fail -closed — an export that cannot be verified is refused.""" +closed — an export that cannot be verified is refused. The single exception is +a finding the anonymized *text* download carries too (a passage the reviewer +kept that also occurs in redacted form): that one is refused with +`forceable: true` and exported only after the reviewer confirms it.""" import hashlib import json from fastapi import APIRouter, Depends, HTTPException, Request, Response +from fastapi.responses import JSONResponse from pydantic import TypeAdapter, ValidationError from starlette.datastructures import UploadFile @@ -30,6 +34,7 @@ from ....utils.ocr_profiles import OcrProfileError, resolve_vision_ocr_profile from ....utils.pdf_export import ( ExportError, + export_detail, rebuild_scanned_pdf, redact_native_pdf, render_pdf_pages, @@ -95,6 +100,29 @@ def _parse_terms(raw) -> list[str] | None: raise HTTPException(status_code=422, detail="Invalid terms payload.") from None +def _export_error_response(exc: ExportError) -> JSONResponse: + """A refused export, as a body the UI can act on. + + `detail` is looked up from the code rather than taken off the exception, so + no message an upstream library produced can reach the client; it stays the + English sentence every other error route returns, so a client that does not + know the code keeps showing something sensible. `code` and `forceable` are + what tells the review UI whether to offer "export anyway", and `items` are + the passages that would stay visible so the confirmation can name them — + up to a display cap, which is why `count` is reported separately. They are + document content and go to the client only — never to a log.""" + return JSONResponse( + status_code=exc.status_code, + content={ + "detail": export_detail(exc.code, exc.count), + "code": exc.code, + "forceable": exc.forceable, + "items": exc.items, + "count": exc.count, + }, + ) + + @router.post("/export/pdf") async def export_pdf( request: Request, @@ -132,6 +160,11 @@ async def export_pdf( # page, so a reconstruction looks like the native export instead of reading # its replacements out in words. redaction_bars = _parse_bool(form.get("redaction_bars")) + # The reviewer confirmed a refused export whose only finding is that a + # redacted string also occurs outside the redacted passages — i.e. the PDF + # would show exactly what the anonymized text download already shows. + # Ignored by every other check; nothing else can be waved through. + force_export = _parse_bool(form.get("force_export")) raw_profile = form.get("ocr_profile") ocr_profile = ( raw_profile.strip() if isinstance(raw_profile, str) and raw_profile.strip() else None @@ -161,7 +194,14 @@ async def export_pdf( try: if source_type == "pdf": - pdf_bytes = redact_native_pdf(data, result.entities, settings, areas=redact_areas) + pdf_bytes = redact_native_pdf( + data, + result.entities, + settings, + areas=redact_areas, + expected_text=result.anonymized_text, + force=force_export, + ) elif source_type == "pdf-ocr": pdf_bytes = rebuild_scanned_pdf( result.source_text, @@ -170,13 +210,15 @@ async def export_pdf( page_count, areas=redact_areas, bars=redaction_bars, + expected_text=result.anonymized_text, + force=force_export, ) else: raise HTTPException( status_code=415, detail="Redacted-PDF export is available for PDF uploads only." ) except ExportError as exc: - raise HTTPException(status_code=exc.status_code, detail=str(exc)) from None + return _export_error_response(exc) logger.info( "export_pdf", @@ -184,6 +226,7 @@ async def export_pdf( source_type=source_type, entities=len(result.entities), areas=len(redact_areas), + forced=force_export, size=len(pdf_bytes), ) return Response( @@ -210,7 +253,9 @@ async def export_pdf_pages( try: pages, truncated = render_pdf_pages(data) except ExportError as exc: - raise HTTPException(status_code=exc.status_code, detail=str(exc)) from None + raise HTTPException( + status_code=exc.status_code, detail=export_detail(exc.code, exc.count) + ) from None logger.info("export_pdf_pages", pages=len(pages), truncated=truncated) return PdfPagesResponse.model_validate({"pages": pages, "truncated": truncated}) diff --git a/backend/src/utils/pdf_export.py b/backend/src/utils/pdf_export.py index 661005f..ab661b4 100644 --- a/backend/src/utils/pdf_export.py +++ b/backend/src/utils/pdf_export.py @@ -15,6 +15,18 @@ entirely (fail-closed); the anonymized text is re-typeset at the OCR bounding boxes (0–1000 normalized). The result is re-extracted and checked: no redacted entity string may survive in the output. + +Both verifications compare against the anonymized TEXT output (`expected_text`) +rather than against an empty page, because the two are not the same claim: + +* A redacted string that survives in the PDF but NOT in the text output means + a blackout was not applied — the export machinery failed. Refused, always. +* A redacted string that survives in BOTH is the reviewer's own doing: they + kept one of several identical passages, or a detector found only one + occurrence of it. The PDF is then exactly as redacted as the text download + the user already has, and `leakage.py` has already flagged it as a HIGH + residual. Refused with `forceable=True` so the UI can offer "export anyway" + — never exported silently. """ import base64 @@ -38,10 +50,89 @@ _SHORT_NEEDLE_MAX = 3 +# Stable codes for the export failures, mirrored under `errors.export.*` in +# the locale catalogs. +EXPORT_FAILED = "pdf_export_failed" +RESIDUAL_EXPLAINED = "pdf_export_residual_explained" +RESIDUAL_UNEXPLAINED = "pdf_export_residual_unexplained" +NOT_LOCATED = "pdf_export_not_located" +AREA_RESIDUAL = "pdf_export_area_residual" +NO_LAYOUT = "pdf_export_no_layout" +PDF_UNREADABLE = "pdf_export_unreadable" +PDF_NO_PAGES = "pdf_export_no_pages" + +# One English sentence per code — the ONLY text an export failure ever puts in +# front of a caller. Written here rather than at the throw site so that the +# endpoint can build its response from the code alone: nothing derived from the +# raised exception, let alone from an upstream library's message, can reach the +# client. `{count}` is filled from the error's own count where it has one. +EXPORT_DETAILS: dict[str, str] = { + EXPORT_FAILED: "The redacted PDF could not be generated.", + RESIDUAL_EXPLAINED: ( + "{count} redacted text(s) also occur outside the passages that were redacted and " + "therefore stay visible in the PDF — exactly as they do in the anonymized text. " + "The PDF was not generated; confirm to export it anyway." + ), + RESIDUAL_UNEXPLAINED: ( + "Verification failed: a redacted string is still present in the exported PDF; " + "the export was aborted." + ), + NOT_LOCATED: ( + "{count} redacted item(s) could not be located in the PDF text layer; the " + "redacted PDF was NOT generated. The anonymized text download remains safe to use." + ), + AREA_RESIDUAL: ( + "Verification failed: text is still present under a blacked-out area; the " + "redacted PDF was NOT generated." + ), + NO_LAYOUT: ( + "No layout information is available for this document; re-run the anonymization " + "and export again." + ), + PDF_UNREADABLE: "The PDF could not be opened.", + PDF_NO_PAGES: "The PDF contains no pages.", +} + +# Cap on the residual strings echoed back to the client. They are document +# content: the client already holds the source text and the entity list, so +# this is nothing new to it — but it never goes to a log. +_MAX_REPORTED_RESIDUALS = 20 + + class ExportError(Exception): - def __init__(self, message: str, status_code: int = 422): - super().__init__(message) + """A refused export, identified by its `code`. + + The code carries the message (`EXPORT_DETAILS`), the UI's translation key + and the decision the endpoint makes; there is no free-text message, so a + throw site cannot leak an upstream error into the response by accident. + + `forceable` marks the one failure the reviewer may overrule (see the module + docstring) and `items` are the strings that would stay visible, so the + confirmation can name them. `count` is how many there are, which is NOT + `len(items)` once the list is capped for display — a notice that says "20" + about 37 findings understates exactly the thing it exists to state. + """ + + def __init__( + self, + code: str = EXPORT_FAILED, + *, + status_code: int = 422, + forceable: bool = False, + items: list[str] | None = None, + count: int | None = None, + ): + self.code = code self.status_code = status_code + self.forceable = forceable + self.items = items or [] + self.count = len(self.items) if count is None else count + super().__init__(export_detail(code, self.count)) + + +def export_detail(code: str, count: int = 0) -> str: + """The English sentence for an export failure code.""" + return EXPORT_DETAILS.get(code, EXPORT_DETAILS[EXPORT_FAILED]).format(count=count) def _page_areas( @@ -144,10 +235,7 @@ def _verify_areas(data: bytes, areas: list[RedactArea] | None) -> None: x0 * page_width, y0 * page_height, x1 * page_width, y1 * page_height ) if any(word[4].strip() for word in page.get_text("words", clip=rect)): - raise ExportError( - "Verification failed: text is still present under a blacked-out " - "area; the redacted PDF was NOT generated." - ) + raise ExportError(AREA_RESIDUAL) finally: document.close() @@ -176,15 +264,24 @@ def redact_native_pdf( entities: list[AppliedEntity], settings: Settings, areas: list[RedactArea] | None = None, + *, + expected_text: str = "", + force: bool = False, ) -> bytes: try: - return _redact_native_true(data, entities, settings, areas) - except Exception as exc: - logger.warning( - "true_redaction_fallback", - reason=type(exc).__name__, + return _redact_native_true( + data, entities, settings, areas, expected_text=expected_text, force=force ) - return _redact_native_raster(data, entities, settings, areas) + except ExportError as exc: + if exc.forceable: + # Not a machinery failure — the rasterizer would black out the same + # passages and stop on the same finding, only after a full render, + # and its own error would hide the one the reviewer can act on. + raise + logger.warning("true_redaction_fallback", reason=type(exc).__name__) + except Exception as exc: + logger.warning("true_redaction_fallback", reason=type(exc).__name__) + return _redact_native_raster(data, entities, settings, areas) def _redact_native_true( @@ -192,6 +289,9 @@ def _redact_native_true( entities: list[AppliedEntity], settings: Settings, areas: list[RedactArea] | None = None, + *, + expected_text: str = "", + force: bool = False, ) -> bytes: import pymupdf @@ -254,10 +354,7 @@ def _redact_native_true( missing = [n for n in needles if n not in found and not _covered(n, found)] if missing: - raise ExportError( - f"{len(missing)} redacted item(s) could not be located in the PDF text " - "layer; the redacted PDF was NOT generated." - ) + raise ExportError(NOT_LOCATED, count=len(missing)) # Scrub metadata, XMP, embedded/attached files, JavaScript, hidden text. try: @@ -269,7 +366,7 @@ def _redact_native_true( finally: document.close() - _verify_native(output, needles) + _verify_native(output, needles, expected_text, force) _verify_areas(output, areas) return output @@ -324,7 +421,52 @@ def _needle_survives(needle: str, text: str, collapsed: str, compact: str | None return _compact(needle) in compact -def _verify_native(output: bytes, needles: list[str]) -> None: +def _surviving(needles: list[str], text: str) -> list[str]: + """The needles that are still present in `text`, in the given order.""" + collapsed = re.sub(r"\s+", " ", text) + compact = _compact(text) + return [needle for needle in needles if _needle_survives(needle, text, collapsed, compact)] + + +def _check_residuals( + output_text: str, + needles: list[str], + expected_text: str, + force: bool, + location: str, +) -> None: + """Classify the redacted strings that survived in the exported PDF. + + Split by whether the anonymized TEXT output has them too (see the module + docstring): one is the reviewer's decision, the other is a broken export. + An empty `expected_text` means "nothing may survive", the strict default + for callers that have no text output to compare against.""" + surviving = _surviving(needles, output_text) + if not surviving: + return + + explained = set(_surviving(surviving, expected_text)) if expected_text else set() + unexplained = [needle for needle in surviving if needle not in explained] + if unexplained: + # Not in the text output, so no reviewer decision put it there: a + # blackout that should have covered it did not. Never forceable. + logger.warning("export_residual_unexplained", count=len(unexplained), location=location) + raise ExportError(RESIDUAL_UNEXPLAINED) + + if not force: + raise ExportError( + RESIDUAL_EXPLAINED, + forceable=True, + items=surviving[:_MAX_REPORTED_RESIDUALS], + count=len(surviving), + ) + + logger.warning("export_forced_residuals", count=len(surviving), location=location) + + +def _verify_native( + output: bytes, needles: list[str], expected_text: str = "", force: bool = False +) -> None: """Mandatory: re-extract the redacted PDF and assert no redacted string survived anywhere in the remaining text layer.""" import pymupdf @@ -334,14 +476,7 @@ def _verify_native(output: bytes, needles: list[str]) -> None: text = "\n".join(page.get_text() for page in document) finally: document.close() - collapsed = re.sub(r"\s+", " ", text) - compact = _compact(text) - for needle in needles: - if _needle_survives(needle, text, collapsed, compact): - raise ExportError( - "Verification failed: a redacted string is still present in the " - "redacted PDF's text layer." - ) + _check_residuals(text, needles, expected_text, force, "redacted PDF's text layer") def _redact_native_raster( @@ -368,7 +503,7 @@ def _redact_native_raster( try: document = pdfium.PdfDocument(data) except Exception as exc: - raise ExportError("The PDF could not be opened for export.", status_code=415) from exc + raise ExportError(PDF_UNREADABLE, status_code=415) from exc try: images = [] for page_index, page in enumerate(document): @@ -434,13 +569,9 @@ def _redact_native_raster( if missing: # Fail closed: the text layer diverges from what we extracted, so a # blackout might be incomplete. Never emit a possibly leaky PDF. - raise ExportError( - f"{len(missing)} redacted item(s) could not be located in the PDF text " - "layer; the redacted PDF was NOT generated. The anonymized text " - "download remains safe to use." - ) + raise ExportError(NOT_LOCATED, count=len(missing)) if not images: - raise ExportError("The PDF contains no pages.", status_code=415) + raise ExportError(PDF_NO_PAGES, status_code=415) buffer = io.BytesIO() images[0].save( @@ -566,13 +697,13 @@ def render_pdf_pages(data: bytes) -> tuple[list[dict], bool]: try: document = pdfium.PdfDocument(data) except Exception as exc: - raise ExportError("The PDF could not be opened.", status_code=415) from exc + raise ExportError(PDF_UNREADABLE, status_code=415) from exc pages: list[dict] = [] try: total = len(document) if total == 0: - raise ExportError("The PDF contains no pages.", status_code=415) + raise ExportError(PDF_NO_PAGES, status_code=415) for index in range(min(total, _MAX_RENDER_PAGES)): page = document[index] width, height = page.get_size() @@ -619,15 +750,15 @@ def rebuild_scanned_pdf( page_count: int, areas: list[RedactArea] | None = None, bars: bool = False, + *, + expected_text: str = "", + force: bool = False, ) -> bytes: from reportlab.lib.colors import grey from reportlab.pdfgen import canvas if not layout: - raise ExportError( - "No layout information is available for this document; " - "re-run the anonymization and export again." - ) + raise ExportError(NO_LAYOUT) width, height = _PAGE_A4 buffer = io.BytesIO() @@ -666,7 +797,7 @@ def rebuild_scanned_pdf( # text would leave that text selectable underneath. output = _apply_areas(output, areas) - _verify_rebuilt(output, entities) + _verify_rebuilt(output, entities, expected_text, force) return output @@ -794,17 +925,23 @@ def _latin1_safe(text: str) -> str: return text.translate(_UNICODE_FALLBACKS).encode("latin-1", errors="replace").decode("latin-1") -def _verify_rebuilt(output: bytes, entities: list[AppliedEntity]) -> None: - """Re-extract the generated PDF and assert no redacted string survived.""" +def _verify_rebuilt( + output: bytes, entities: list[AppliedEntity], expected_text: str = "", force: bool = False +) -> None: + """Re-extract the generated PDF and assert no redacted string survived. + + The rebuild types the anonymized text through `_latin1_safe`, so the + comparison text goes through it too — otherwise a passage the reviewer + kept would read as unexplained purely because an en dash became a hyphen + on the page.""" from pypdf import PdfReader reader = PdfReader(io.BytesIO(output)) extracted = "\n".join(page.extract_text() or "" for page in reader.pages) - collapsed = re.sub(r"\s+", " ", extracted) - compact = _compact(extracted) - for needle in redacted_texts(entities): - if _needle_survives(needle, extracted, collapsed, compact): - raise ExportError( - "Verification failed: a redacted string is still present in the " - "rebuilt PDF; the export was aborted." - ) + _check_residuals( + extracted, + redacted_texts(entities), + _latin1_safe(expected_text) if expected_text else "", + force, + "rebuilt PDF", + ) diff --git a/backend/tests/integration/test_api.py b/backend/tests/integration/test_api.py index 8fd582d..1efe95e 100644 --- a/backend/tests/integration/test_api.py +++ b/backend/tests/integration/test_api.py @@ -676,3 +676,76 @@ def export(**data): extracted = "".join(p.extract_text() or "" for p in PdfReader(io.BytesIO(barred)).pages) assert "Max Mustermann" not in extracted assert "[PERSON_1]" in extracted + + +def test_refused_export_names_its_code_and_whether_it_can_be_forced(client, monkeypatch): + """The contract the review UI's "export anyway" button reads.""" + from backend.src.routers.v1.endpoints import export as export_endpoint + from backend.src.utils import pdf_export + from backend.tests.pdf_builder import make_pdf + + pdf = make_pdf( + [ + "Patient: Max Mustermann, geb. 01.02.1980", + "Der Patient wurde stationaer aufgenommen und komplikationslos behandelt.", + "Die Entlassung erfolgte in gutem Allgemeinzustand nach Hause.", + ] + ) + seen: dict[str, bool] = {} + + def fake_export(data, entities, settings, areas=None, *, expected_text="", force=False): + seen["force"] = force + seen["expected_text"] = bool(expected_text) + if force: + return b"%PDF-forced" + raise pdf_export.ExportError( + pdf_export.RESIDUAL_EXPLAINED, forceable=True, items=["Mustermann"] + ) + + monkeypatch.setattr(export_endpoint, "redact_native_pdf", fake_export) + + refused = client.post( + "/api/v1/export/pdf", files={"file": ("brief.pdf", pdf, "application/pdf")} + ) + assert refused.status_code == 422 + body = refused.json() + assert body["code"] == "pdf_export_residual_explained" + assert body["forceable"] is True + assert body["items"] == ["Mustermann"] + assert isinstance(body["detail"], str) # unchanged for older clients + # The exporter is handed the anonymized text to judge the residual against. + assert seen["expected_text"] is True + + forced = client.post( + "/api/v1/export/pdf", + files={"file": ("brief.pdf", pdf, "application/pdf")}, + data={"force_export": "true"}, + ) + assert forced.status_code == 200 + assert seen["force"] is True + + +def test_a_refused_export_never_logs_the_passages_it_names(client, caplog, monkeypatch): + import logging + + from backend.src.routers.v1.endpoints import export as export_endpoint + from backend.src.utils import pdf_export + from backend.tests.pdf_builder import make_pdf + + pdf = make_pdf( + [ + "Patient: Max Mustermann, geb. 01.02.1980", + "Der Patient wurde stationaer aufgenommen und komplikationslos behandelt.", + "Die Entlassung erfolgte in gutem Allgemeinzustand nach Hause.", + ] + ) + + def fake_export(data, entities, settings, areas=None, *, expected_text="", force=False): + raise pdf_export.ExportError( + pdf_export.RESIDUAL_EXPLAINED, forceable=True, items=["Mustermann"] + ) + + monkeypatch.setattr(export_endpoint, "redact_native_pdf", fake_export) + with caplog.at_level(logging.INFO): + client.post("/api/v1/export/pdf", files={"file": ("brief.pdf", pdf, "application/pdf")}) + assert "Mustermann" not in caplog.text diff --git a/backend/tests/unit/test_pdf_export.py b/backend/tests/unit/test_pdf_export.py index 20ad82e..08ce8c1 100644 --- a/backend/tests/unit/test_pdf_export.py +++ b/backend/tests/unit/test_pdf_export.py @@ -776,3 +776,116 @@ def test_true_redaction_really_does_black_out_the_preserved_duplicate(): # Both are gone, including the one the reviewer chose to keep. assert "Mueller" not in extracted.replace("\n", "") assert pdf_export.preserved_texts_at_risk(entities) == ["Mueller"] + + +# --- a residual the anonymized TEXT has too: warn, never silently export ------ + + +def _twin_name_document() -> tuple[str, list[LayoutLine], list[AppliedEntity], str]: + """A scanned report where the patient and the treating physician share a + first name, and the reviewer kept the physician's. The rebuild applies + replacements by OFFSET, so the kept occurrence stays on the page — which is + exactly what the anonymized text download shows too.""" + lines = ["Patientin: Anna Musterfrau", "Aerztin: Anna Beispiel"] + source = "\n".join(lines) + patient = source.index("Anna") + doctor = source.index("Anna", patient + 1) + entities = [ + applied("Anna", patient, replacement="[PERSON_1]"), + applied("Anna", doctor, replacement=None), + ] + expected = source[:patient] + "[PERSON_1]" + source[patient + 4 :] + return source, make_layout(source, lines), entities, expected + + +def test_rebuild_without_a_reference_text_still_refuses_any_residual(): + source, layout, entities, _ = _twin_name_document() + with pytest.raises(ExportError) as excinfo: + rebuild_scanned_pdf(source, layout, entities, page_count=1) + assert excinfo.value.forceable is False + + +def test_rebuild_offers_to_force_a_residual_the_text_output_has_too(): + source, layout, entities, expected = _twin_name_document() + with pytest.raises(ExportError) as excinfo: + rebuild_scanned_pdf(source, layout, entities, page_count=1, expected_text=expected) + error = excinfo.value + assert error.code == pdf_export.RESIDUAL_EXPLAINED + assert error.forceable is True + # Named, so the confirmation can say what stays visible. + assert error.items == ["Anna"] + + +def test_forced_rebuild_exports_and_keeps_the_passage_the_reviewer_kept(): + from pypdf import PdfReader + + source, layout, entities, expected = _twin_name_document() + output = rebuild_scanned_pdf( + source, layout, entities, page_count=1, expected_text=expected, force=True + ) + extracted = "\n".join(p.extract_text() or "" for p in PdfReader(io.BytesIO(output)).pages) + assert "[PERSON_1]" in extracted + assert "Anna Beispiel" in extracted + assert "Anna Musterfrau" not in extracted + + +def test_force_cannot_wave_through_a_residual_the_text_output_redacted(): + """The machinery-failure case: the text output has no "Anna", the PDF does. + That is a blackout that did not happen — no confirmation may pass it.""" + source, layout, entities, _ = _twin_name_document() + both_redacted = source.replace("Anna", "[PERSON_1]") + with pytest.raises(ExportError) as excinfo: + rebuild_scanned_pdf( + source, layout, entities, page_count=1, expected_text=both_redacted, force=True + ) + error = excinfo.value + assert error.code == pdf_export.RESIDUAL_UNEXPLAINED + assert error.forceable is False + + +def test_native_export_does_not_fall_back_to_raster_on_a_forceable_finding(monkeypatch): + """The rasterizer would stop on the same finding after a full render, and + report it as an unforceable one — the reviewer would lose the button.""" + text = "Befund von Mueller.\nZweitmeinung von Mueller.\n" + first = text.index("Mueller") + second = text.index("Mueller", first + 1) + entities = [applied("Mueller", first), applied("Mueller", second, replacement=None)] + monkeypatch.setattr( + pdf_export, + "_redact_native_raster", + lambda *args, **kwargs: pytest.fail("rasterized a forceable finding"), + ) + # Both occurrences are blacked out, so make the text layer the arbiter: + # a stubbed verification that reports the kept one as surviving. + monkeypatch.setattr( + pdf_export, + "_verify_native", + lambda output, needles, expected_text="", force=False: pdf_export._check_residuals( + "Zweitmeinung von Mueller.", needles, expected_text, force, "test" + ), + ) + with pytest.raises(ExportError) as excinfo: + redact_native_pdf( + make_pdf(text), + entities, + Settings(), + expected_text="Befund von [PERSON_1].\nZweitmeinung von Mueller.\n", + ) + assert excinfo.value.forceable is True + + +def test_every_export_code_has_an_english_sentence(): + """The response is built from the code alone, so a code without a sentence + would silently degrade to the generic one.""" + codes = { + value + for name, value in vars(pdf_export).items() + if name.isupper() and isinstance(value, str) and value.startswith("pdf_export_") + } + assert codes == set(pdf_export.EXPORT_DETAILS) + + +def test_an_export_error_says_exactly_what_its_code_says(): + error = pdf_export.ExportError(pdf_export.NOT_LOCATED, count=3) + assert str(error) == pdf_export.export_detail(pdf_export.NOT_LOCATED, 3) + assert "3 redacted item(s)" in str(error) diff --git a/docs/RISK_REGISTER.md b/docs/RISK_REGISTER.md index 8810b50..09d2890 100644 --- a/docs/RISK_REGISTER.md +++ b/docs/RISK_REGISTER.md @@ -26,7 +26,7 @@ listed controls. | S1 | The app is exposed without the auth proxy | Backend publishes no port; checklist; docs state the requirement repeatedly; optional built-in OIDC gate (`OIDC_ENABLED`) for deployments with no proxy, which refuses to start half-configured | **Low**, if reviewed at deployment | Operator | | S2 | Prompt injection from document content | Fenced document markers, untrusted-data system prompt, strings-only model output, deterministic grounding, independent validation | **Low** for integrity; contributes to P1 | Developers | | S3 | Parser vulnerability in a document library | Extension allow-list, size caps, read-only non-root container, no persistence, Dependabot + CI scanning | **Low** | Developers | -| S4 | A redacted PDF that is not actually redacted | Text removal + box coverage, post-export verification, fail-closed refusal, reconstruction for scans | **Low** | Developers | +| S4 | A redacted PDF that is not actually redacted | Text removal + box coverage, post-export verification, fail-closed refusal, reconstruction for scans. The single overridable finding is one the anonymized text carries too, is named to the reviewer, and stays on screen after the export | **Low** | Developers | | S5 | Dependency compromise | Pinned lockfiles, weekly updates, CodeQL/pip-audit/npm audit/Trivy | **Medium** — CI is currently manual-trigger only | Developers | | S6 | A leaked request id lets someone re-run a cached document | Unguessable ids, 15-minute TTL, no listing endpoint, authenticated callers | **Low** | — | diff --git a/docs/index.md b/docs/index.md index 9769ddf..7a27ee7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -40,7 +40,8 @@ Document → extraction → rule + LLM detection → span merging re-runs the deterministic transformation server-side. - **Redacted PDF export.** Native PDFs get true blackout at the character boxes; scanned PDFs are rebuilt from the anonymized text. Both fail closed — - an export that cannot be verified is refused. + an export that cannot be verified is refused, except for a finding the + anonymized text download carries too, which the reviewer may confirm. - **An evaluation harness** for scoring the pipeline against annotated ground truth, reporting document-level leakage alongside the usual metrics. diff --git a/docs/user-guide/export.md b/docs/user-guide/export.md index 90f3d9f..a71e4ef 100644 --- a/docs/user-guide/export.md +++ b/docs/user-guide/export.md @@ -33,6 +33,38 @@ Both paths **fail closed**. If the redaction cannot be verified afterwards, the export is refused with an error rather than handing you a file that looks redacted but is not. +### When the check finds text you kept + +One finding is not a failure of the export, and it is the one you are most +likely to meet: a redacted text that **also occurs outside the redacted +passages**. Two everyday examples — + +- the patient and the treating physician share a first name, and you kept the + physician's; +- a phrase was redacted in one place and occurs again in a section where no + detector found it. + +Applying your decisions by position leaves that occurrence standing, so the PDF +would show it — but so does the anonymized text you can already download, and +the [validation](validation.md) has flagged it there. Refusing the PDF would +only make it stricter than the text. + +So the export stops, names the passages that would stay visible, and offers +**Trotzdem exportieren**. The **Geschwärztes PDF** panel then keeps a notice +saying the document was exported despite an open finding, for as long as it is +on screen. Confirm once per finding: adjusting other entities does not ask +again, a *new* passage does. + +This applies to reconstructed (scanned) PDFs. A native PDF removes every +occurrence of a redacted text, so it does not run into it — see the warning +below for what that costs instead. + +Everything else stays refused with no way around it, because it means a +blackout did not apply: a redacted text still in the PDF that the anonymized +text does *not* contain, a passage the exporter could not locate on the page, +or text surviving under an area you blacked out. Use the text export in that +case, and report the document. + ### Black bars in a rebuilt document The two paths look different by nature: a native PDF blacks its redactions out, diff --git a/frontend/components/anonymizer/ResultView.vue b/frontend/components/anonymizer/ResultView.vue index 13e4974..7c16316 100644 --- a/frontend/components/anonymizer/ResultView.vue +++ b/frontend/components/anonymizer/ResultView.vue @@ -376,6 +376,23 @@ > {{ noticeMessage(pdfPreserveNotice) }}
+ ++ {{ + t( + 'result.pdf.forced_notice', + { count: session.pdfExportForced.count }, + session.pdfExportForced.count, + ) + }} +
{{ session.pdfExportBlock.message }}
+{{ t('result.pdf.export_anyway_hint') }}
+
{{ session.pdfPreviewError }}
@@ -572,8 +616,6 @@ import { usePopover } from '@/composables/usePopover'
import { useResultLifetime } from '@/composables/useResultLifetime'
import { useToast } from '@/composables/useToast'
import { useFileDownload } from '@/composables/useFileDownload'
-import { anonymizeApi } from '@/services/anonymizeApi'
-import { extractPdfExportErrorMessage } from '@/utils/errors'
import type { MatchRange } from '@/utils/textSegments'
import { getBannerClass } from '@/utils/statusStyles'
import { entityTypeLabel, sourceTypeLabel } from '@/utils/entityLabels'
@@ -584,7 +626,7 @@ const { t } = useI18n()
const session = useSessionStore()
const settings = useSettingsStore()
const toast = useToast()
-const { downloadBlob, downloadFromApi } = useFileDownload()
+const { downloadBlob } = useFileDownload()
/** The ACTIVE document of the batch — everything below renders its state. */
const doc = computed(() => session.activeDocument)
@@ -998,38 +1040,27 @@ const exportingPdf = ref(false)
*/
async function downloadRedactedPdf() {
const entry = doc.value
- const file = entry?.file
- const requestId = entry?.result?.request_id
- if (!entry || !file || requestId === undefined || exportingPdf.value) return
+ if (!entry || entry.file === null || entry.result === null || exportingPdf.value) return
if (entry.pdfPreviewBlob !== null && !entry.pdfPreviewLoading) {
downloadBlob(entry.pdfPreviewBlob, exportFilename('pdf'))
return
}
- const overrides = [...entry.overrides.values()]
exportingPdf.value = true
try {
- await downloadFromApi(
- () =>
- anonymizeApi.exportPdf(
- file,
- requestId,
- overrides,
- entry.policy,
- entry.rules,
- entry.redactAreas,
- // These matter only on a backend cache miss, where the document is
- // re-extracted and re-transformed: without them the fallback would
- // skip the forced OCR, use another OCR profile, and write German
- // placeholders.
- entry.forceOcr,
- entry.ocrProfile,
- entry.outputLanguage,
- settings.redactionBars,
- ),
- exportFilename('pdf'),
- )
- } catch (err) {
- toast.error(await extractPdfExportErrorMessage(err))
+ // The same request the preview panel makes (overrides, areas, OCR profile
+ // and output language included), so a refusal lands where the "export
+ // anyway" button is instead of in a dead-end toast.
+ await session.refreshPdfPreview()
+ if (entry.pdfPreviewBlob !== null) {
+ downloadBlob(entry.pdfPreviewBlob, exportFilename('pdf'))
+ return
+ }
+ if (entry.pdfExportBlock !== null) {
+ session.activatePanel('pdf')
+ toast.error(entry.pdfExportBlock.message)
+ } else if (entry.pdfPreviewError !== null) {
+ toast.error(entry.pdfPreviewError)
+ }
} finally {
exportingPdf.value = false
}
diff --git a/frontend/locales/de.json b/frontend/locales/de.json
index 8156544..9dc5f23 100644
--- a/frontend/locales/de.json
+++ b/frontend/locales/de.json
@@ -236,7 +236,11 @@
"reconstructed": "Rekonstruiertes Dokument – Layout angenähert, Originalpixel werden verworfen.",
"generating": "PDF wird erzeugt …",
"preview_title": "Geschwärztes PDF (Vorschau)",
- "no_preview": "Keine Vorschau verfügbar."
+ "no_preview": "Keine Vorschau verfügbar.",
+ "export_anyway": "Trotzdem exportieren",
+ "export_anyway_hint": "Das PDF zeigt dann dieselben Stellen wie der anonymisierte Text.",
+ "forced_notice": "Dieses PDF wurde trotz eines offenen Prüfbefunds erzeugt: 1 geschwärzter Text ist darin weiterhin sichtbar. | Dieses PDF wurde trotz eines offenen Prüfbefunds erzeugt: {count} geschwärzte Texte sind darin weiterhin sichtbar.",
+ "export_more": "und {count} weitere"
},
"entities": {
"section_label": "Erkannte Entitäten",
@@ -420,7 +424,16 @@
"backend_unreachable": "Server nicht erreichbar. Bitte prüfen Sie, ob das Backend läuft.",
"pdf_export_failed": "PDF-Export fehlgeschlagen. Bitte versuchen Sie es erneut.",
"pdf_export_detail": "PDF-Export fehlgeschlagen: {detail}",
- "unauthorized": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an."
+ "unauthorized": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
+ "export": {
+ "pdf_export_residual_explained": "PDF nicht erzeugt: {count} geschwärzte Textstelle(n) kommen auch außerhalb der geschwärzten Passagen vor und blieben im PDF sichtbar – genau wie im anonymisierten Text.",
+ "pdf_export_residual_unexplained": "Prüfung fehlgeschlagen: Eine Schwärzung wurde im PDF nicht angewendet. Das PDF wurde nicht erzeugt.",
+ "pdf_export_not_located": "Prüfung fehlgeschlagen: Nicht alle geschwärzten Inhalte konnten im PDF gefunden werden. Das PDF wurde nicht erzeugt.",
+ "pdf_export_area_residual": "Prüfung fehlgeschlagen: Unter einem geschwärzten Bereich ist noch Text vorhanden. Das PDF wurde nicht erzeugt.",
+ "pdf_export_no_layout": "Für dieses Dokument liegen keine Layout-Informationen vor. Bitte die Anonymisierung erneut ausführen und dann exportieren.",
+ "pdf_export_unreadable": "Das PDF konnte nicht geöffnet werden.",
+ "pdf_export_no_pages": "Das PDF enthält keine Seiten."
+ }
},
"toast": {
"copy_success": "Anonymisierter Text in die Zwischenablage kopiert.",
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 7bf38d1..a244337 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -236,7 +236,11 @@
"reconstructed": "Reconstructed document – layout approximated, the original pixels are discarded.",
"generating": "Generating PDF …",
"preview_title": "Redacted PDF (preview)",
- "no_preview": "No preview available."
+ "no_preview": "No preview available.",
+ "export_anyway": "Export anyway",
+ "export_anyway_hint": "The PDF will then show the same passages as the anonymized text.",
+ "forced_notice": "This PDF was created despite an unresolved finding: 1 redacted text is still visible in it. | This PDF was created despite an unresolved finding: {count} redacted texts are still visible in it.",
+ "export_more": "and {count} more"
},
"entities": {
"section_label": "Detected entities",
@@ -420,7 +424,16 @@
"backend_unreachable": "Server unreachable. Please check whether the backend is running.",
"pdf_export_failed": "PDF export failed. Please try again.",
"pdf_export_detail": "PDF export failed: {detail}",
- "unauthorized": "Your session has expired. Please sign in again."
+ "unauthorized": "Your session has expired. Please sign in again.",
+ "export": {
+ "pdf_export_residual_explained": "PDF not created: {count} redacted text(s) also occur outside the redacted passages and would stay visible in the PDF — exactly as they do in the anonymized text.",
+ "pdf_export_residual_unexplained": "Verification failed: a redaction was not applied in the PDF. The PDF was not created.",
+ "pdf_export_not_located": "Verification failed: not all redacted content could be located in the PDF. The PDF was not created.",
+ "pdf_export_area_residual": "Verification failed: text is still present under a blacked-out area. The PDF was not created.",
+ "pdf_export_no_layout": "No layout information is available for this document. Run the anonymization again and export afterwards.",
+ "pdf_export_unreadable": "The PDF could not be opened.",
+ "pdf_export_no_pages": "The PDF contains no pages."
+ }
},
"toast": {
"copy_success": "Anonymized text copied to the clipboard.",
diff --git a/frontend/locales/es.json b/frontend/locales/es.json
index acd51fb..f1fe038 100644
--- a/frontend/locales/es.json
+++ b/frontend/locales/es.json
@@ -236,7 +236,11 @@
"reconstructed": "Documento reconstruido: diseño aproximado, los píxeles originales se descartan.",
"generating": "Generando el PDF …",
"preview_title": "PDF censurado (vista previa)",
- "no_preview": "No hay vista previa disponible."
+ "no_preview": "No hay vista previa disponible.",
+ "export_anyway": "Exportar de todos modos",
+ "export_anyway_hint": "El PDF mostrará entonces los mismos pasajes que el texto anonimizado.",
+ "forced_notice": "Este PDF se creó a pesar de un hallazgo sin resolver: 1 texto redactado sigue visible en él. | Este PDF se creó a pesar de un hallazgo sin resolver: {count} textos redactados siguen visibles en él.",
+ "export_more": "y {count} más"
},
"entities": {
"section_label": "Entidades detectadas",
@@ -420,7 +424,16 @@
"backend_unreachable": "Servidor no accesible. Compruebe si el backend está en marcha.",
"pdf_export_failed": "La exportación a PDF ha fallado. Inténtelo de nuevo.",
"pdf_export_detail": "La exportación a PDF ha fallado: {detail}",
- "unauthorized": "Su sesión ha caducado. Vuelva a iniciar sesión."
+ "unauthorized": "Su sesión ha caducado. Vuelva a iniciar sesión.",
+ "export": {
+ "pdf_export_residual_explained": "PDF no creado: {count} texto(s) redactado(s) aparecen también fuera de los pasajes redactados y quedarían visibles en el PDF, igual que en el texto anonimizado.",
+ "pdf_export_residual_unexplained": "Verificación fallida: una redacción no se aplicó en el PDF. El PDF no se creó.",
+ "pdf_export_not_located": "Verificación fallida: no se pudo localizar todo el contenido redactado en el PDF. El PDF no se creó.",
+ "pdf_export_area_residual": "Verificación fallida: todavía hay texto bajo un área tachada. El PDF no se creó.",
+ "pdf_export_no_layout": "No hay información de diseño para este documento. Vuelva a ejecutar la anonimización y expórtelo después.",
+ "pdf_export_unreadable": "No se pudo abrir el PDF.",
+ "pdf_export_no_pages": "El PDF no contiene páginas."
+ }
},
"toast": {
"copy_success": "Texto anonimizado copiado al portapapeles.",
diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json
index 995cbc6..f4e5313 100644
--- a/frontend/locales/fr.json
+++ b/frontend/locales/fr.json
@@ -236,7 +236,11 @@
"reconstructed": "Document reconstruit – mise en page approximative, les pixels d’origine sont supprimés.",
"generating": "Génération du PDF …",
"preview_title": "PDF caviardé (aperçu)",
- "no_preview": "Aucun aperçu disponible."
+ "no_preview": "Aucun aperçu disponible.",
+ "export_anyway": "Exporter quand même",
+ "export_anyway_hint": "Le PDF affichera alors les mêmes passages que le texte anonymisé.",
+ "forced_notice": "Ce PDF a été créé malgré un constat non résolu : 1 texte caviardé y reste visible. | Ce PDF a été créé malgré un constat non résolu : {count} textes caviardés y restent visibles.",
+ "export_more": "et {count} de plus"
},
"entities": {
"section_label": "Entités détectées",
@@ -420,7 +424,16 @@
"backend_unreachable": "Serveur injoignable. Veuillez vérifier que le backend fonctionne.",
"pdf_export_failed": "L’export PDF a échoué. Veuillez réessayer.",
"pdf_export_detail": "L’export PDF a échoué : {detail}",
- "unauthorized": "Votre session a expiré. Veuillez vous reconnecter."
+ "unauthorized": "Votre session a expiré. Veuillez vous reconnecter.",
+ "export": {
+ "pdf_export_residual_explained": "PDF non créé : {count} texte(s) caviardé(s) apparaissent aussi en dehors des passages caviardés et resteraient visibles dans le PDF — exactement comme dans le texte anonymisé.",
+ "pdf_export_residual_unexplained": "Échec de la vérification : un caviardage n'a pas été appliqué dans le PDF. Le PDF n'a pas été créé.",
+ "pdf_export_not_located": "Échec de la vérification : tous les contenus caviardés n'ont pas pu être localisés dans le PDF. Le PDF n'a pas été créé.",
+ "pdf_export_area_residual": "Échec de la vérification : du texte subsiste sous une zone noircie. Le PDF n'a pas été créé.",
+ "pdf_export_no_layout": "Aucune information de mise en page n'est disponible pour ce document. Relancez l'anonymisation, puis exportez.",
+ "pdf_export_unreadable": "Le PDF n'a pas pu être ouvert.",
+ "pdf_export_no_pages": "Le PDF ne contient aucune page."
+ }
},
"toast": {
"copy_success": "Texte anonymisé copié dans le presse-papiers.",
diff --git a/frontend/services/anonymizeApi.ts b/frontend/services/anonymizeApi.ts
index a68001b..1297979 100644
--- a/frontend/services/anonymizeApi.ts
+++ b/frontend/services/anonymizeApi.ts
@@ -159,6 +159,7 @@ export const anonymizeApi = {
ocrProfile?: string | null,
outputLanguage?: OutputLanguage | null,
redactionBars?: boolean,
+ force?: boolean,
): Promise