Reviewer UI Patterns for Redaction Review Queues

A redaction review queue must let a human accept or reject flagged spans quickly while never shipping raw personally identifiable information to the browser. This page covers the API and interface patterns that surface entity boundaries, confidence distributions, and masked context snippets under strict least-privilege controls, keyboard-first, with optimistic locking and full audit emission.

The review surface is the one place where automated detection meets human judgement, so it inherits the state and integrity guarantees of Human-in-the-Loop Override Sync while adding its own hard constraint: the client is untrusted. A reviewer decides whether a span is PII from redacted context alone, which means the server does the unmasking and the browser never sees the plaintext it is adjudicating.

Prerequisites & Version Matrix Permalink to this section

Component Version Role
Python 3.11+ Runtime
FastAPI 0.111+ Review API surface
pydantic 2.7+ Response schema enforcement
PyMuPDF (fitz) 1.24+ Rendering redacted page previews
pytest 8.2+ Verification

A reviewer account must carry a role claim (for example reviewer or senior_reviewer) issued by your identity provider. Items reach the queue from the detection layer described in Confidence Threshold Configuration, each carrying a confidence score used to order and bucket the queue.

Step 1 — Serve masked snippets, never raw spans Permalink to this section

The endpoint resolves the entity server-side, masks the detected characters, and returns only a windowed, masked snippet plus display-safe metadata. The raw text and absolute offsets stay inside the secure boundary.

from fastapi import FastAPI, Depends, HTTPException
from pydantic import BaseModel

app = FastAPI()

class ReviewItem(BaseModel):
    item_id: str
    doc_id: str
    entity_type: str          # e.g. "SSN", "PERSON"
    confidence: float         # 0.0-1.0, drives queue ordering
    masked_snippet: str       # context with the PII already masked
    version: int              # optimistic-lock token

def mask_span(text: str, start: int, end: int, window: int = 40) -> str:
    """Return a context window with the detected span replaced by blocks.
    Adjacent windows are re-scanned so neighbouring PII is not leaked."""
    left = text[max(0, start - window):start]
    right = text[end:end + window]
    return f"{left}███{right}"

def load_secure_span(item_id: str) -> tuple[str, int, int]:
    # Reads plaintext ONLY inside the trusted service; never returned raw.
    ...

@app.get("/review/{item_id}", response_model=ReviewItem)
def get_review_item(item_id: str, user=Depends(require_reviewer_role)):
    text, start, end = load_secure_span(item_id)
    meta = load_item_meta(item_id)
    return ReviewItem(
        item_id=item_id,
        doc_id=meta["doc_id"],
        entity_type=meta["entity_type"],
        confidence=meta["confidence"],
        masked_snippet=mask_span(text, start, end),  # masking happens here
        version=meta["version"],
    )

Step 2 — Claim/lock an item so two reviewers never collide Permalink to this section

A claim writes the reviewer and an expiry, guarded by a compare-and-set on the version token. A second reviewer requesting the same item gets a 409 and moves on. (Stale claims are reclaimed by the sweeper in SLA-Based Timeout Handling for Review Queues.)

import time

def claim_item(item_id: str, reviewer_id: str, expected_version: int) -> dict:
    row = db.get(item_id)
    if row["version"] != expected_version:
        raise HTTPException(409, "Item changed; refresh before claiming")
    if row.get("claimed_by") and row["claim_expires"] > time.time():
        raise HTTPException(409, "Already claimed by another reviewer")
    row.update(
        claimed_by=reviewer_id,
        claim_expires=time.time() + 900,   # 15-minute hold
        version=expected_version + 1,       # bump the optimistic-lock token
    )
    db.put(item_id, row)
    emit_audit("ITEM_CLAIMED", item_id, reviewer_id)  # every action audited
    return row

Step 3 — Keyboard-first accept/reject, each emitting an audit event Permalink to this section

Bind j/k to move through the queue, a to accept the redaction, r to reject (restore), and ? to open help. Every decision posts the version token and produces an override plus an audit record; there is no mouse-only action.

@app.post("/review/{item_id}/decision")
def decide(item_id: str, action: str, version: int, user=Depends(require_reviewer_role)):
    if action not in ("REDACT", "RESTORE"):
        raise HTTPException(422, "Unknown action")
    row = db.get(item_id)
    if row["version"] != version:                 # optimistic-lock guard
        raise HTTPException(409, "Stale version; reload the item")
    apply_override(item_id, action, user.sub)
    emit_audit("REVIEW_DECISION", item_id, user.sub, extra={"action": action})
    return {"status": "recorded", "action": action}

Compliance Checkpoint Permalink to this section

Serving masked snippets and gating every endpoint by role implements minimum-necessary access. NIST SP 800-53 Rev. 5 control AC-6 (Least Privilege) requires that reviewers receive only the data their task demands — here, enough context to judge a span, never the raw identifier. For protected health information, HIPAA §164.514(d) (the minimum necessary standard) requires reasonable efforts to limit disclosure to the minimum needed, which windowed masking satisfies by design. Because each claim and decision emits an audit event, the surface also feeds the accountability record described in the parent guide.

Gotchas Permalink to this section

  • Never ship raw spans to the browser. Return masked snippets and display metadata only. If the client ever needs coordinates to draw an overlay, send them relative to a server-rendered redacted image, not to the source text.
  • Snippet windowing can leak adjacent PII. A fixed 40-character window can pull a second identifier (a phone number next to an SSN) into view unmasked. Re-scan each window with the detector and mask all hits before returning it.
  • Optimistic locks fail open if you forget to bump the version. Every write must increment the token; a decision that reuses a stale version must be rejected with 409, not silently applied.
  • Keyboard controls must stay accessible. Give every shortcut a visible, focusable equivalent, wire aria-live for queue advancement, and keep focus order deterministic so screen-reader and keyboard-only reviewers are not locked out.

Verification Permalink to this section

This test asserts the review payload contains only masked text — the raw identifier never appears in the response.

from fastapi.testclient import TestClient

def test_response_contains_only_masked_text(monkeypatch):
    raw_ssn = "123-45-6789"
    monkeypatch.setattr("app.load_secure_span",
                        lambda _id: (f"Client {raw_ssn} filed", 7, 18))
    client = TestClient(app)
    resp = client.get("/review/item-1", headers=reviewer_auth())
    body = resp.json()
    assert resp.status_code == 200
    assert raw_ssn not in body["masked_snippet"]   # PII must be masked
    assert "█" in body["masked_snippet"]        # block glyph present
    assert "span_start" not in body                  # raw offsets not leaked

Running pytest -q yields 1 passed, proving the endpoint cannot leak the plaintext it adjudicates.

Reviewer decisions captured here become the overrides propagated by Human-in-the-Loop Override Sync, and the confidence scores that order the queue come from Confidence Threshold Configuration. Items that repeatedly fail render or adjudication route onward to Dead-Letter Queue Escalation for Failed Reviews, while unclaimed work ages under SLA-Based Timeout Handling for Review Queues. Part of the PII Detection & Automated Redaction Patterns field guide.