From 96fdacf0b71431ffd4431d2117c4d3364ed5118e Mon Sep 17 00:00:00 2001 From: FWao Date: Mon, 24 Aug 2026 11:38:27 +0200 Subject: [PATCH 1/2] Let the reviewer confirm a PDF export refused by a residual the text output has too --- AGENTS.md | 8 +- CHANGELOG.md | 9 + backend/src/routers/v1/endpoints/export.py | 46 ++++- backend/src/utils/pdf_export.py | 174 +++++++++++++++--- backend/tests/integration/test_api.py | 76 ++++++++ backend/tests/unit/test_pdf_export.py | 96 ++++++++++ docs/RISK_REGISTER.md | 2 +- docs/index.md | 3 +- docs/user-guide/export.md | 32 ++++ frontend/components/anonymizer/ResultView.vue | 89 ++++++--- frontend/locales/de.json | 14 +- frontend/locales/en.json | 14 +- frontend/locales/es.json | 14 +- frontend/locales/fr.json | 14 +- frontend/services/anonymizeApi.ts | 5 + frontend/stores/session.ts | 90 ++++++++- frontend/utils/errors.test.ts | 42 +++++ frontend/utils/errors.ts | 72 ++++++-- 18 files changed, 710 insertions(+), 90 deletions(-) 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..a5bf639 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 @@ -95,6 +99,27 @@ 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` stays the English sentence every other error route returns, so an + older client 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": str(exc), + "code": exc.code, + "forceable": exc.forceable, + "items": exc.items, + "count": exc.count, + }, + ) + + @router.post("/export/pdf") async def export_pdf( request: Request, @@ -132,6 +157,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 +191,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 +207,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 +223,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( diff --git a/backend/src/utils/pdf_export.py b/backend/src/utils/pdf_export.py index 661005f..40ab378 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,45 @@ _SHORT_NEEDLE_MAX = 3 +# Stable codes for the export failures the UI reacts to, 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" + +# 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): + """`code` is stable and translated by the UI. `forceable` marks the one + failure the reviewer may overrule (see the module docstring); `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, + message: str, + status_code: int = 422, + *, + code: str = EXPORT_FAILED, + forceable: bool = False, + items: list[str] | None = None, + count: int | None = None, + ): super().__init__(message) self.status_code = status_code + self.code = code + self.forceable = forceable + self.items = items or [] + self.count = len(self.items) if count is None else count def _page_areas( @@ -146,7 +193,8 @@ def _verify_areas(data: bytes, areas: list[RedactArea] | None) -> None: 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." + "area; the redacted PDF was NOT generated.", + code=AREA_RESIDUAL, ) finally: document.close() @@ -176,15 +224,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 +249,9 @@ def _redact_native_true( entities: list[AppliedEntity], settings: Settings, areas: list[RedactArea] | None = None, + *, + expected_text: str = "", + force: bool = False, ) -> bytes: import pymupdf @@ -256,7 +316,8 @@ def _redact_native_true( 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." + "layer; the redacted PDF was NOT generated.", + code=NOT_LOCATED, ) # Scrub metadata, XMP, embedded/attached files, JavaScript, hidden text. @@ -269,7 +330,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 +385,58 @@ 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. + raise ExportError( + f"Verification failed: a redacted string is still present in the {location}; " + "the export was aborted.", + code=RESIDUAL_UNEXPLAINED, + ) + + if not force: + raise ExportError( + f"{len(surviving)} 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.", + code=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 +446,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( @@ -619,6 +724,9 @@ 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 @@ -666,7 +774,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 +902,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..3bf7e37 100644 --- a/backend/tests/integration/test_api.py +++ b/backend/tests/integration/test_api.py @@ -676,3 +676,79 @@ 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( + "1 redacted text(s) also occur outside the redacted passages.", + code=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( + "refused", code=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..406d97c 100644 --- a/backend/tests/unit/test_pdf_export.py +++ b/backend/tests/unit/test_pdf_export.py @@ -776,3 +776,99 @@ 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 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, + ) + }} +