Skip to content

Add PII scan and auto-annotate agent tool - #1624

Merged
JSv4 merged 13 commits into
mainfrom
feature/pii-scan-and-annotate-tool
May 13, 2026
Merged

Add PII scan and auto-annotate agent tool#1624
JSv4 merged 13 commits into
mainfrom
feature/pii-scan-and-annotate-tool

Conversation

@JSv4

@JSv4 JSv4 commented May 12, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • New scan_and_annotate_pii agent tool that scans documents for sensitive information via the privacy-filter microservice and creates one labeled annotation per detection — TOKEN_LABEL via PlasmaPDF for PDFs, SPAN_LABEL for plain text.
  • Async httpx client with chunking (40K / 500 overlap), offset re-mapping, and overlap dedup. 8 PII categories (email, phone, person name, address, account number, URL, date, secret) auto-mapped to colored/iconed AnnotationLabels via Corpus.ensure_label_and_labelset.
  • Agent knobs: min_score, entity_groups allowlist, dry_run preview mode, start_char/end_char to scope the scan to a slice. New privacy_filter Docker service in local.yml (with required: false for dev opt-out) and production.yml (strict ${PRIVACY_FILTER_API_KEY:?...}). Three new settings: PRIVACY_FILTER_URL, PRIVACY_FILTER_API_KEY, PRIVACY_FILTER_TIMEOUT_SECONDS.

Test plan

  • All new tests pass: docker compose -f test.yml run --rm django pytest opencontractserver/tests/test_pii_scan_tool.py opencontractserver/tests/test_privacy_filter_client.py -n 0 -v (expect 18 passed: 12 tool + 6 client)
  • Tool resolves at agent runtime: docker compose -f test.yml run --rm django python -c "from opencontractserver.llms.tools.tool_registry import ToolFunctionRegistry; print(ToolFunctionRegistry.get().to_core_tool('scan_and_annotate_pii') is not None)" prints True
  • Public import surface intact: docker compose -f test.yml run --rm django python -c "from opencontractserver.llms.tools.core_tools import ascan_and_annotate_pii, ENTITY_GROUP_LABELS; print(len(ENTITY_GROUP_LABELS))" prints 8
  • Live smoke against the service (optional, pulls a multi-GB image): docker compose -f local.yml up -d privacy_filter && docker compose -f local.yml exec django curl -sf -X POST http://privacy_filter:8000/v1/detect -H "X-API-Key: dev-only-not-secret" -H "Content-Type: application/json" -d '{"text":"Email me at alice@example.com tomorrow."}'
  • Before production deploy: confirm PRIVACY_FILTER_API_KEY is set in the env or the stack will fail fast at startup (intentional — production fail-closed)

Adds `scan_and_annotate_pii`, an agent tool that scans documents for
sensitive information via the privacy-filter microservice and creates
one labeled annotation per detection (TOKEN_LABEL via PlasmaPDF for
PDFs, SPAN_LABEL for plain text).

- New httpx async client with chunking, offset remap, and dedup
- 8 PII categories auto-mapped to colored/iconed AnnotationLabels
- Agent knobs: min_score, entity_groups, dry_run, start_char/end_char
- New privacy_filter Docker service (local.yml, production.yml)
- Settings: PRIVACY_FILTER_URL, PRIVACY_FILTER_API_KEY, _TIMEOUT_SECONDS
- 18 unit tests; privacy-filter client mocked so CI needs no image
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: Add PII scan and auto-annotate agent tool

Overview

This PR introduces a scan_and_annotate_pii agent tool that integrates with an external privacy_filter microservice to detect and annotate PII in documents. The architecture is clean — the HTTP client is properly separated from the annotation logic, async/sync boundaries are handled correctly with _db_sync_to_async, and the chunking/dedup approach is sound. Test coverage is solid. A few issues need attention before merging.


🔴 Critical

production.yml makes privacy_filter a hard required dependency

# production.yml — no `required: false`
privacy_filter:
  condition: service_started

local.yml correctly uses required: false, but production.yml does not. Without required: false, Docker Compose will refuse to start the django and celeryworker services if privacy_filter fails to start. This will break all existing production deployments on upgrade — operators who don't need PII scanning will suddenly be unable to start their stack.

The setting PRIVACY_FILTER_URL defaults to an empty string and the tool already raises a descriptive RuntimeError when unconfigured, which is adequate fail-closed behavior. The hard Docker dependency isn't necessary to achieve that goal and creates a breaking change. Recommend adding required: false to the production dependency block too, or documenting this as an explicit upgrade step.


🟠 High

Unhandled httpx network exceptions (_privacy_filter_client.py)

The adetect_pii function calls client.post(...) but does not catch httpx.TimeoutException, httpx.ConnectError, or other transport-level exceptions. Per the project's tool fault-tolerance model (CLAUDE.md §Agent Tool Architecture), operational exceptions should be caught and returned as error strings to the LLM, not propagate as unhandled. Only PermissionError / ToolConfirmationRequired should propagate.

# Current — network errors bubble up uncaught
resp = await client.post(detect_url, json={"text": chunk}, headers=headers)

# Suggested — wrap in try/except httpx.HTTPError
try:
    resp = await client.post(detect_url, json={"text": chunk}, headers=headers)
except httpx.HTTPError as exc:
    raise RuntimeError(f"privacy-filter request failed: {exc}") from exc

httpx not added to requirements

The diff doesn't touch any requirements/*.txt file. If httpx is only a transitive dependency today, a future dep update could silently break this. It should be pinned explicitly.


🟡 Medium

entity_groups allowlist silently drops unknown group names

If an agent passes entity_groups=["typo_email"], the detection is silently filtered out with no feedback. The tool should validate the allowlist against ENTITY_GROUP_LABELS and return an error for unrecognised names, otherwise agents can pass bad values without knowing.

File type handling is undocumented in the tool description

The tool only supports text/plain, application/txt, and application/pdf. Passing a docx or html document raises a ValueError which gets swallowed by the fault-tolerance layer. The tool description string in tool_registry.py should mention the supported formats so agents don't invoke it on unsupported document types.

No retry / backoff for transient HTTP failures

A single 503 from the privacy-filter service immediately fails the entire scan. A simple retry (e.g., 2–3 attempts with exponential backoff) would make this more robust in practice, especially since large documents generate multiple sequential chunk requests.


⚪ Low / Nits

Fragile test assertion in ScanAndAnnotatePdfTests

assert idx >= 0, "Test fixture must contain the string 'Agreement'."

The test is tightly coupled to the content of SAMPLE_PAWLS_FILE_ONE_PATH. If the fixture is ever regenerated this will fail with a confusing message. Consider picking the first word from layer.doc_text dynamically instead of hardcoding "Agreement".

Duplicate entity_group validation across two functions

ascan_and_annotate_pii already filters unknown groups (the continue at line ~397), so _persist_annotations_sync's _label_for will never be called with an unknown group. The second guard is harmless but adds noise — a comment explaining "defensive; callers should pre-filter" would help.

Irregular import order in tool_registry.py

The new import ascan_and_annotate_pii is inserted before aapply_caml_article_edit in the import list but not in alphabetical order relative to its neighbours. Minor, but inconsistent with the rest of the file.


✅ What's done well

  • Clean HTTP client / tool boundary; the client has no Django knowledge, the tool has no HTTP knowledge.
  • Offset remapping and overlap dedup logic is carefully implemented and well tested.
  • transaction.atomic() wrapping the annotation batch prevents partial writes.
  • Label caching within a single call (label_cache dict) avoids N+1 queries to ensure_label_and_labelset.
  • dry_run mode is genuinely useful for previewing before committing writes.
  • requires_approval=True is the right call for a tool that can create hundreds of annotations.
  • TransactionTestCase is the correct base class for async tests with signals.
  • CI doesn't need the live container — the mock coverage is thorough.

- Mark privacy_filter as required: false in production.yml so existing prod
  deployments that don't run the PII service still start.
- Wrap the httpx.post() call in adetect_pii in try/except httpx.HTTPError
  and re-raise as RuntimeError so transport-level failures land in the
  agent fault-tolerance layer instead of bubbling out raw.
- Validate the entity_groups allowlist up-front in ascan_and_annotate_pii
  and raise ValueError for unknown names instead of silently filtering
  every detection.
- Mention supported file types (PDF + plain text) in the scan_and_annotate_pii
  tool description.
- Add regression tests for the new transport-failure path and the
  allowlist validation.
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: PR #1624 — PII Scan & Auto-Annotate Agent Tool

Overall this is a well-structured addition. The chunking/dedup logic is sound, the fault-tolerance contract with the agent framework is correctly respected, and the test coverage is solid. A few items worth addressing before merge:


Bugs / Correctness

1. Non-standard MIME type in _load_doc_text_sync
file_type in {"application/txt", "text/plain"}application/txt is not a standard MIME type. If the project consistently stores text/plain, the first string is dead code. If some older documents carry application/txt, that's a data-quality issue worth documenting (ideally in a comment or a migration). Either way, the set should reflect reality.

2. Import order in core_tools/__init__.py breaks alphabetical convention
The new from .pii import ... is inserted between from .memory import ... and from .notes import .... Since n < p alphabetically, notes should precede pii. Minor but inconsistent with the rest of the file.

3. Bare type annotation without assignment in ascan_and_annotate_pii

allowlist: set[str] | None
if entity_groups:
    ...
    allowlist = set(entity_groups)
else:
    allowlist = None

The bare annotation on its own line is valid Python but unusual and could confuse static analysis tools that expect assignments before use. Prefer allowlist: set[str] | None = None followed by the conditional reassignment.


Security

4. httpx not in requirements?
The client imports httpx directly. If httpx isn't already a declared dependency (only requests or similar), this will fail at import time in a fresh install. Worth confirming httpx is in requirements/base.txt.

5. Error messages could expose partial API key context
raise RuntimeError(f"privacy-filter request failed: {exc.__class__.__name__}: {exc}")httpx exceptions include request metadata in __str__ which typically does not contain headers (good), but it's worth a quick audit to ensure the X-API-Key header value doesn't leak into exception output in edge cases.


Production Behaviour Inconsistency

6. PR description says "fail-closed" but production.yml says required: false
The PR summary states:

"Before production deploy: confirm PRIVACY_FILTER_API_KEY is set in the env or the stack will fail fast at startup (intentional — production fail-closed)"

But production.yml sets required: false on the privacy_filter dependency for both django and celeryworker, meaning the stack starts fine without the service running. The ${PRIVACY_FILTER_API_KEY:?} syntax only triggers if someone explicitly tries to start the privacy_filter container. These two behaviours are not in conflict but the PR description is misleading — it should be clarified to say the tool returns a RuntimeError gracefully when the service is absent, and the key is only mandatory when the service container is launched.


Code Quality / Minor Issues

7. Return type annotation is too loose
async def ascan_and_annotate_pii(...) -> dict — the shape is well-documented in the docstring, but -> dict[str, Any] (or a TypedDict) would be more useful for callers and static analysis.

8. local.yml privacy_filter service has no ports mapping
This is fine for Docker-network-only access (Django/Celery reach it on the internal bridge), but the live smoke-test in the PR description runs curl from inside the django container, which works. A note in the PR or a comment in local.yml clarifying "internal-only, no host port" would prevent confusion for developers who try to hit the service from the host.

9. No httpx.AsyncClient per-call connection pool reuse
A new httpx.AsyncClient context is opened for every adetect_pii call. For a one-shot agent tool this is fine, but since each call can issue multiple chunk requests, consider whether a module-level client (with configured limits) would be better. Not a blocker given the access pattern.


Test Coverage

The test suite is thorough and covers:

  • Text and PDF annotation creation ✅
  • All agent knobs (min_score, entity_groups, dry_run, start_char/end_char) ✅
  • OOB detection skipping ✅
  • Unsupported file types ✅
  • Document-not-in-corpus ✅
  • Transport failures and non-2xx responses ✅
  • Tool registry flags and runtime resolution ✅
  • Multi-chunk offset remapping and dedup ✅

One gap: there is no test for start_char > end_char (inverted range). The code handles this correctly (if s >= e: return early), but an explicit test would document this contract.


Summary

Severity Issue
⚠️ Medium Item 4 — confirm httpx is in requirements/base.txt
⚠️ Medium Item 6 — production description is misleading
🔵 Low Items 1, 3, 7 — MIME type set, bare annotation, return type
🔵 Low Items 2, 8, 9 — import order, ports comment, client lifecycle
🟢 Coverage Item 10 — add inverted-range edge case test

The core logic (chunking, dedup, PlasmaPDF integration, label auto-creation) is well-implemented and the fault-tolerance contract with PydanticAIToolWrapper is correctly followed. Addressing items 4 and 6 before merge would be the main priorities.

- isort reordered core_tools/__init__.py to alphabetize the new pii import
  ahead of notes/page_images per project convention.
- Removed bare-annotation-then-assign pattern in ascan_and_annotate_pii;
  the variable is now initialised at the annotation site so static
  analysers (and humans) see one clear binding.
- Tightened the return type to dict[str, Any] so callers and IDEs get a
  more useful contract than bare ``dict``.
- Added a docs comment to the local.yml privacy_filter service noting it
  is internal-bridge-only with no host port mapping (smoke-test from
  inside django container).
- New regression test covers the inverted start_char/end_char case: the
  tool must short-circuit and must not issue a request to the
  privacy-filter service.
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review — PR #1624: PII Scan & Auto-Annotate Agent Tool

Overall this is a solid, well-structured addition. The async patterns are correct, the test suite is thorough, and the failure-mode design (RuntimeError → agent error string) fits the existing fault-tolerance contract. A few items worth addressing:


Potential Bug: Production Docker Compose Variable Expansion

production.ymlprivacy_filter service

environment:
  API_KEYS: ${PRIVACY_FILTER_API_KEY:?PRIVACY_FILTER_API_KEY must be set}

Docker Compose evaluates ${VAR:?error} substitutions at parse time for all services, not only for services that are actually started. Combined with required: false in depends_on, the intent is clearly "opt-in service" — but an operator who runs docker compose -f production.yml up without PRIVACY_FILTER_API_KEY set will get a hard error even if they never intended to run the privacy_filter container. This defeats the required: false opt-out.

Options:

  1. Use ${PRIVACY_FILTER_API_KEY:-} (soft fallback) and rely on PRIVACY_FILTER_URL being unset to disable the tool, or
  2. Document explicitly that deployers must always set the variable (even to an empty placeholder), or
  3. Move privacy_filter into a named Compose profile (e.g., --profile privacy) so it only runs when opted in.

Minor Issues

Magic string file types in pii.py

Per CLAUDE.md ("No magic numbers"), bare string literals like "application/pdf" and "text/plain" should be pulled from the project's constants files (opencontractserver/constants/). Several other tools already reference file-type constants from there.

Non-standard MIME type retained

_load_doc_text_sync accepts "application/txt" alongside "text/plain". The former isn't a registered MIME type. If existing documents in production carry this type that's defensible — but a comment explaining why would prevent future confusion.

Sequential chunk processing

adetect_pii issues HTTP requests to the privacy-filter service one chunk at a time. For large documents (e.g., a 200-page contract → 5+ chunks), parallel asyncio.gather over chunks would cut wall-clock time proportionally. The dedup set is already chunk-order-agnostic so the change would be straightforward:

import asyncio

async def _fetch_chunk(client, detect_url, headers, chunk_start, text):
    chunk = text[chunk_start : chunk_start + CHUNK_SIZE]
    resp = await client.post(detect_url, json={"text": chunk}, headers=headers)
    ...
    return chunk_start, detections

results = await asyncio.gather(*[_fetch_chunk(..., s, text) for s in starts])

Redundant bounds check

_persist_annotations_sync re-validates start < 0 or end > len(doc_text) or start >= end after the same check already ran in ascan_and_annotate_pii. The inner check is defensive but since the two functions are always called together the double-check adds noise without extra safety guarantees.

_label_for raises on unknown group but the call site already filtered

The ValueError("Unknown entity_group …") in _label_for is dead code in practice — ascan_and_annotate_pii already rejects unknown groups before calling _persist_annotations_sync, and detections are only passed through if they're in ENTITY_GROUP_LABELS. Consider removing the check or replacing it with an assert to make the invariant explicit.


Test Coverage — Praise

The 18-test suite is comprehensive:

  • Chunk offset re-mapping and overlap dedup are tested at the exact boundary
  • Transport failures, non-2xx responses, and unconfigured service are all covered
  • The registry tests confirm the tool is actually reachable at agent runtime

The one gap: there's no test verifying that corpus_action_id is propagated to Annotation.corpus_action_id when provided. Low risk given the code path is straightforward, but easy to add.


Nit: local.yml API key comment

API_KEYS: dev-only-not-secret

The inline comment on the service block already calls this out well. Consider adding a corresponding entry to .env.example (if the project has one) so developers know this env var exists.


Summary

Area Status
Async correctness ✅ All tools are async, DB calls go through _db_sync_to_async
Tool registration ✅ Registered with requires_approval=True, requires_write_permission=True
Error contract ✅ RuntimeError → agent error string path works correctly
Test coverage ✅ 18 tests, comprehensive
httpx dependency ✅ Already in requirements/base.txt
Production deploy safety ⚠️ ${...:?} expansion may break opt-out deployments
Constants convention ⚠️ File-type strings should use project constants
Performance ℹ️ Sequential chunks; parallel would be a nice follow-up

@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: PII Scan & Auto-Annotate Agent Tool

Overview

This PR adds a scan_and_annotate_pii agent tool that sends document text to a privacy_filter microservice and creates labeled annotations per detected PII entity. The architecture is clean: a thin async HTTP client handles chunking/dedup, and the tool layer handles ORM writes and label management. The overall approach is solid.


Bugs / Correctness Issues

🔴 High: production.yml — opt-in comment contradicts hard mandatory env var

production.yml marks the privacy_filter dependency as required: false and comments:

"Opt-in service: existing deployments that don't run a privacy filter must still be able to start django / celeryworker."

But the service definition itself uses:

API_KEYS: ${PRIVACY_FILTER_API_KEY:?PRIVACY_FILTER_API_KEY must be set}

Docker Compose performs variable substitution before trying to start any container. If PRIVACY_FILTER_API_KEY is unset, docker compose -f production.yml up fails at parse time — no containers start, including django and celeryworker. The required: false flag only governs what happens when the container itself fails after starting. This will silently break any existing production deployment that upgrades without setting the new env var.

Fix options (pick one):

  • Use ${PRIVACY_FILTER_API_KEY:-} to allow an empty default, then document that an empty key disables the service.
  • Use a Compose profile (profiles: [pii]) so the service only activates when explicitly requested: docker compose --profile pii up.
  • If strict fail-closed behaviour is truly intended, remove the "opt-in" comment and update the upgrade docs to call this out as a breaking change.

🟡 Medium: Overlap dedup discards higher-confidence scores

In _privacy_filter_client.py, the dedup key is (start, end, entity_group). When the same detection appears in two overlapping chunks (which is the whole point of the overlap), the first chunk's score is kept. If chunk 2 returns a higher-confidence score for the same span, it is silently dropped.

This is not a crash bug, but it means annotations can carry the lower confidence score rather than the best one. Consider keeping the detection with the maximum score:

# Instead of `if key in seen: continue`, track scores:
if key in seen:
    if float(det["score"]) > results[seen[key]]["score"]:
        results[seen[key]] = Detection(...)  # replace with better score
    continue
seen[key] = len(results)
results.append(...)

Security Considerations

No issues with the API key handling — it goes over the internal Docker bridge only, which is the correct pattern.

Permission enforcement: The ascan_and_annotate_pii function takes creator_id but does not verify that the user corresponding to creator_id has write access to the corpus or document. The tool's requires_write_permission=True flag delegates enforcement to the framework. This is consistent with how other tools in this codebase are structured, but it's worth explicitly calling out in a comment that permission pre-checks happen at the registry/framework layer, not here.


Code Quality

✅ Good patterns throughout:

  • requires_approval=True on a write tool is correct and important.
  • dry_run mode lets agents preview without side effects.
  • Transport-level errors are wrapped as RuntimeError so the tool fault-tolerance layer can convert them to LLM-readable error strings — consistent with CLAUDE.md §Agent Tool Architecture.
  • The _label_for inner cache avoids redundant DB calls per detection within a transaction.
  • entity_groups allowlist validation catches typos eagerly rather than silently returning 0 results.

Minor code style notes:

  • by_group counter could use collections.Counter for readability, but the current dict .get(key, 0) + 1 pattern is correct.
  • float(min_score) is redundant given the parameter is already typed float, but it's harmless.
  • The import json as _json alias at module level is slightly unconventional; import json and using json.load() directly is clearer.

Infrastructure

🟡 No healthcheck on privacy_filter: Both local.yml and production.yml use condition: service_started, which only checks that the container process spawned — not that the HTTP service is accepting connections. If the model weights take time to load, the django/celeryworker containers may start and attempt tool calls before the endpoint is ready.

Consider adding:

healthcheck:
  test: ["CMD", "curl", "-sf", "http://localhost:8000/health"]
  interval: 10s
  retries: 5

and changing the dependency to condition: service_healthy.

No resource limits: The privacy_filter service runs an ML model CPU-only. Without memory/CPU limits it can starve other containers on resource-constrained hosts. Not blocking, but worth a follow-up.


Test Coverage

The 18 tests are well-structured and cover the important cases. A few gaps worth noting:

  1. No permission boundary test: There is no test verifying that a user without write access to the corpus cannot invoke the tool. Given this is a write tool with PII implications, a test exercising the framework's requires_write_permission guard would strengthen confidence.

  2. API key empty-but-URL-set: No test for the case where PRIVACY_FILTER_URL is set but PRIVACY_FILTER_API_KEY is empty. Currently, the client sends an empty X-API-Key header without warning. A test (or a guard in the client) for this case would prevent silent auth failures in misconfigured deployments.

  3. Duplicate annotation prevention: No test verifying that running the tool twice on the same document doesn't create duplicate annotations (idempotency). If the LLM retries the tool on failure this could be a real scenario.


Summary

Category Status
Core logic (chunking, dedup, annotation creation) ✅ Correct
Async/sync boundary handling ✅ Correct
production.yml opt-in vs ${...:?} contradiction 🔴 Must fix before merge
Overlap score dedup 🟡 Minor — consider improving
Healthcheck / service_healthy 🟡 Nice-to-have
Test coverage — permission boundary 🟡 Recommended addition
Test coverage — duplicate-run idempotency 🟡 Recommended addition

The production.yml env var issue is the only change I'd consider blocking — everything else is polished and well-thought-out. Great addition overall.

- production.yml: switch privacy_filter API_KEYS to empty default
  (`${PRIVACY_FILTER_API_KEY:-}`). The previous `:?required` form failed
  Compose interpolation at parse time, so the whole production stack
  refused to start unless the env var was set — contradicting the
  `required: false` opt-in declared on the depends_on entry.
- production.yml + local.yml: add HTTP /health healthcheck and a 20s
  start_period so dependents can wait on `service_healthy` once the
  model weights are loaded, instead of `service_started` which only
  proves the container process spawned.
- _privacy_filter_client.py: when chunk overlap produces a duplicate
  (start, end, entity_group) detection, keep the higher-confidence
  score rather than first-seen. Switches the dedup tracker from
  `set` to `dict[..., int]` indexing into `results`.
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: PII Scan & Auto-Annotate Agent Tool

Overall this is a well-structured addition. The async HTTP client, chunking/overlap/dedup logic, tool registration, and test coverage are all solid. A few issues worth addressing before merge.


Medium Issues

1. TEXT_MIMETYPES constant exists but isn't used (pii.py)

opencontractserver/constants/document_processing.py already defines TEXT_MIMETYPES = {"text/plain", MARKDOWN_MIME_TYPE, "application/txt"}. The inline set in _load_doc_text_sync duplicates this:

# Current (pii.py line ~75)
if file_type in {"application/txt", "text/plain"}:

# Should be
from opencontractserver.constants.document_processing import TEXT_MIMETYPES
if file_type in TEXT_MIMETYPES:

This also silently excludes Markdown files from PII scanning, which may be intentional, but worth a comment if so.

2. detection_count can be higher than actual annotations created

ascan_and_annotate_pii sets detection_count = len(detections) before calling _persist_annotations_sync, which has its own OOB guard that silently skips invalid spans. In the non-dry_run path this means the returned detection_count and by_entity_group can overcount relative to len(annotation_ids). The most likely source of divergence is a bug in slice-offset remapping — which is exactly the case that would be hardest to debug from a return dict where detection_count: 3 but annotation_ids: [...] has 2 entries. Consider computing by_group and count from new_ids after persistence, or at minimum surfacing skipped_count when len(new_ids) != len(detections).

3. Production API key silently defaults to empty

The CHANGELOG test plan says "stack will fail fast at startup" if PRIVACY_FILTER_API_KEY is unset. production.yml was changed to ${PRIVACY_FILTER_API_KEY:-} (empty default) specifically to avoid aborting the whole compose parse — which is the right call for required: false — but the test plan note is now misleading. More importantly: with an empty API_KEYS, the service starts with no authentication. It's only accessible over the Docker bridge (no ports: mapping), but that still means any compromised sidecar container can call it unauthenticated. Consider emitting a Django startup warning (warnings.warn) or a checks.py system check when PRIVACY_FILTER_URL is set but PRIVACY_FILTER_API_KEY is empty, so operators get visible feedback rather than a silent misconfiguration.

4. No retry logic in the HTTP client

A single transient timeout or TCP reset permanently fails the entire tool invocation — the LLM sees an error string and the whole annotation run is lost. Given that large documents trigger many sequential HTTP requests (every 39.5 KB of text), even a 1% per-request failure rate compounds quickly. A simple retry-with-backoff (e.g. tenacity with 3 attempts) on httpx.HTTPError and 5xx responses would significantly improve reliability in practice. Not a blocker but worth a follow-up issue.


Minor Issues

5. Hardcoded chunk constants should be in the constants file

Per project conventions (CLAUDE.md): "No magic numbers — use constants files." CHUNK_SIZE = 40_000 and CHUNK_OVERLAP = 500 in _privacy_filter_client.py are hardcoded. They should live in opencontractserver/constants/document_processing.py (alongside the existing embedder chunk constants) so they're discoverable and adjustable without diving into the private client module.

6. _load_doc_text_sync 5-tuple return

The unnamed 5-tuple (doc, corpus, doc_text, file_type, pdf_layer) is fragile to positional misreads. A NamedTuple or dataclass would make call sites self-documenting:

@dataclass
class _DocTextResult:
    doc: Document
    corpus: Corpus
    doc_text: str
    file_type: str
    pdf_layer: Any  # None for non-PDF

Nits

  • pii.py imports from typing import Any but Any is only used in type annotations for the _load_doc_text_sync return and pdf_layer. With from __future__ import annotations already present, the Any usage in annotations is fine — just noting it's imported only for that.
  • The test_dedup_when_overlap_returns_same_detection test verifies that the count is 1 but doesn't check which score was kept (higher vs first). Since adetect_pii keeps the higher score on collision, a test asserting detections[0]["score"] == max(score1, score2) would pin that contract.
  • In test_pdf_doc_creates_token_label_annotations, "Agreement" in ann.raw_text is a loose assertion. If PlasmaPDF ever trims whitespace differently it could silently match a superset. Asserting exact equality on ann.raw_text would be more robust.

What's Working Well

  • The requires_approval=True flag is the right default for a write-heavy tool that operates autonomously on PII.
  • Wrapping all httpx.HTTPError subtypes as RuntimeError correctly honours the agent fault-tolerance contract documented in CLAUDE.md.
  • transaction.atomic() around all annotation writes ensures the document is either fully annotated or untouched — no partial state.
  • dry_run mode is a thoughtful safety valve for agents to preview before committing.
  • entity_groups typo validation is exactly the right UX — a silent empty result from "private_emial" would be a painful LLM debugging session.
  • httpx is already in requirements/base.txt — no new dependency needed.
  • Test coverage hits all the critical paths: chunk remapping, overlap dedup, score filtering, allowlist validation, OOB handling, and the registry wiring.

@codecov

codecov Bot commented May 12, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.78238% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
opencontractserver/llms/tools/core_tools/pii.py 92.17% 9 Missing ⚠️
...er/llms/tools/core_tools/_privacy_filter_client.py 95.65% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

CI linter (pre-existing on main):
- Widen requests.post json= payloads in users/tasks.py, docxodus_parser,
  and the two embedder microservices from invariant dict[str, str]
  literals to dict[str, Any] so they satisfy JsonType.

PII scan tool review feedback:
- Use TEXT_MIMETYPES from constants instead of inline {"text/plain",
  "application/txt"} — picks up markdown alongside plain-text and keeps
  the supported-types list in lockstep with the ingestion pipeline.
- Move CHUNK_SIZE / CHUNK_OVERLAP into constants/document_processing.py
  (PRIVACY_FILTER_CHUNK_SIZE / _OVERLAP) so the magic numbers are
  discoverable per CLAUDE.md conventions.
- Convert _load_doc_text_sync's positional 5-tuple to _DocTextResult
  NamedTuple — unpacking sites still work positionally, and named fields
  make the contract self-documenting and grep-friendly.
- Make _persist_annotations_sync return (annotation_id, detection) pairs
  and derive detection_count / by_entity_group from those persisted pairs
  rather than the input detections list. Previously, if any detection
  was filtered out by the OOB / unknown-group guards inside persist,
  detection_count would overcount relative to annotation_ids — a
  confusing failure mode for LLM callers debugging a slice-offset bug.
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: Add PII scan and auto-annotate agent tool

Overall assessment: This is a well-designed, well-tested addition. The architecture is sound, the error-handling contract with the tool fault-tolerance layer is well-documented, and the test coverage is solid. A few items worth addressing before merge.


Issues

Security: Production service accepts unauthenticated requests when PRIVACY_FILTER_API_KEY is unset

production.yml uses API_KEYS: ${PRIVACY_FILTER_API_KEY:-} (empty-string default). If an operator starts the stack without setting PRIVACY_FILTER_API_KEY, the privacy-filter service will run in production with no authentication. Any request to the internal Docker network can call /v1/detect without a key.

Given that the tool also accepts corpus_action_id: int | None = None and hits the service on behalf of the user, a misconfigured deployment could quietly expose PII-bearing document content to whoever can reach the service.

Suggestion: Either keep the required: false dependency (for operators who genuinely opt out of this feature) but log a loud startup warning when the key is empty, or document this risk clearly in a README/docs/ entry so operators know to check it.


Dead code inside _label_for

In _persist_annotations_sync, the calling loop already guards:

if group not in ENTITY_GROUP_LABELS:
    logger.warning(...)
    continue
label_obj = _label_for(group)

But _label_for also raises ValueError for the same condition:

mapping = ENTITY_GROUP_LABELS.get(group)
if mapping is None:
    raise ValueError(f"Unknown entity_group from privacy-filter: {group!r}")

The inner ValueError branch is unreachable. Either remove the guard in the loop and let _label_for handle it (with a continue in the except), or remove the guard inside _label_for. Keeping both creates the impression that there's a meaningful distinction between the two paths.


ensure_label_and_labelset is called inside transaction.atomic() — race condition risk

_persist_annotations_sync wraps everything in transaction.atomic(). Inside that block it calls corpus.ensure_label_and_labelset(), which itself opens a nested transaction.atomic(). ensure_label_and_labelset uses a check-then-create pattern (.filter().first() + .create()) without a lock.

Two concurrent scans targeting the same corpus and PII category could both see label is None and both attempt AnnotationLabel.objects.create(...). The second would raise IntegrityError, which (inside a nested savepoint) marks the outer transaction unusable — rolling back all annotations from the current scan and raising an unhandled exception.

This is pre-existing in ensure_label_and_labelset, but this PR is the first caller inside a long-running atomic() block.
Suggestion: Change the inner create to get_or_create with appropriate unique lookup fields, or at minimum call ensure_label_and_labelset before opening the outer atomic() block (using the already-populated label_cache).


Unrelated type-annotation cleanups bundled into the PR

multimodal_microservice.py, sent_transformer_microservice.py, docxodus_parser.py, and users/tasks.py each have trivial dict[str, str]dict[str, Any] annotation changes. These don't affect behaviour but make the diff harder to review and could mask future bisect investigations. They're better as a separate cleanup commit.


Minor points

Sequential HTTP requests for large documents: adetect_pii issues chunk requests one at a time. For a 500 KB document that's ~13 sequential round-trips. The overlap logic makes this order-independent, so the chunks could be dispatched concurrently with asyncio.gather. Not a blocker for an initial implementation, but worth noting.

_iter_chunk_starts return type annotation says -> list[int] but is correct: No issue — just noting this is fine and intentional.

httpx is already pinned in requirements/base.txt: Confirmed present at >=0.28.1,<1 — no action needed.

requires_approval=True is correctly set: Important for a tool that bulk-writes annotations; good call.

Test coverage is thorough: OOB detection skipping, inverted char range, unsupported file types, unconfigured service, dedup across overlap, multi-chunk offset remapping — all the tricky paths are covered. The use of TransactionTestCase is correct given signal/save interactions.


Summary

Priority Item
Medium Production service starts with empty API_KEYS if env var is unset
Low Dead code in _label_for (unreachable ValueError)
Low Race condition in ensure_label_and_labelset when called inside atomic()
Nit Unrelated type-annotation changes in 4 files should be a separate commit
Nit Consider parallelising chunk requests with asyncio.gather

JSv4 added 2 commits May 11, 2026 23:55
ensure_label_and_labelset uses a check-then-create pattern that races
under concurrent scans. When the losing race fires INSERT inside the
outer transaction.atomic() block, the resulting IntegrityError poisons
the savepoint and rolls back every annotation created by the current
scan. Pull the label get_or_create work up before the atomic block —
the inner block now only inserts Annotation rows, which carry no
cross-row uniqueness constraint.

Also drops the now-dead ValueError inside _label_for: the outer loop
already filters on ENTITY_GROUP_LABELS membership, so a label fetch
for an unknown group is unreachable. Removing the parallel guard keeps
the validation in one place.
@claude

claude Bot commented May 12, 2026

Copy link
Copy Markdown

Code Review: Add PII scan and auto-annotate agent tool

Overall this is a well-structured addition. The chunking logic, dedup strategy, async plumbing, and test coverage are all solid. A few things worth addressing before merge:


Bugs / Correctness

Mixed-document-type corpora will create duplicate label records

_persist_annotations_sync derives label_type_const from file_type at call time, which means a corpus containing both PDFs and plain-text documents will end up with two AnnotationLabel rows for each PII category (one TOKEN_LABEL, one SPAN_LABEL) sharing the same label_text. This means:

  • The "PII: Email" label in the label-set panel appears twice with different types
  • Filtering annotations by label across document types silently splits results

If Corpus.ensure_label_and_labelset disambiguates by (label_text, label_type), this is working as intended — but the ENTITY_GROUP_LABELS constant conflates label identity with annotation type in a non-obvious way. At minimum, document this behavior explicitly, or consider creating TOKEN_LABEL-typed labels for PDFs and SPAN_LABEL-typed labels for text (the current behaviour) but naming them identically — which then surfaces the duplication problem again. Worth a deliberate design decision before it surprises users.


Security

Production service starts with empty API key when PRIVACY_FILTER_API_KEY is unset

production.yml uses API_KEYS: ${PRIVACY_FILTER_API_KEY:-} (empty-string default). If the privacy-filter container accepts an empty API_KEYS value as "no auth required", any process that can reach the internal Docker bridge — including any compromised container — can exfiltrate document text via /v1/detect with no key.

The service has no ports mapping so external exposure isn't the risk; the risk is lateral movement within the compose stack. The comment acknowledges the intentional choice but doesn't say what the privacy-filter image does with API_KEYS: "". Two options:

  1. If the image rejects an empty key list at startup, document that and leave it as-is.
  2. If it allows unauthenticated requests when the key is empty, consider whether that's acceptable for a service that receives raw document text.

Design / Maintainability

_iter_chunk_starts returns a list, not an iterator

The function name implies lazy iteration but it materialises the whole list. Minor, but for very large documents the whole list is trivially small so this isn't a performance concern — it's just a naming mismatch. _chunk_starts or _build_chunk_starts would be clearer.

Chunks are sent sequentially; no backoff or concurrency

adetect_pii issues all chunk requests inside a for loop with sequential await. For a 2 MB document that's ~50 sequential HTTP round-trips against a CPU-bound service. If the privacy-filter service is the bottleneck this is fine, but if the goal is throughput-bound usage consider asyncio.gather with a semaphore to parallelise chunk requests without overwhelming the service.

ENTITY_GROUP_LABELS exported from core_tools/__init__.py as a public API surface

ENTITY_GROUP_LABELS is in __all__ and exported at the package level. The constant is tightly coupled to the privacy-filter service's entity taxonomy. If that taxonomy changes (entity groups renamed/added), callers who imported ENTITY_GROUP_LABELS won't get the update until they re-import from the new location. This is a minor API surface concern — just worth being aware of.


Code Quality

label_type_const naming

The _const suffix is unusual in this codebase and adds no information (it's still a variable). label_type or annotation_label_type would be clearer.

Comment in ascan_and_annotate_pii explains what the code does

)(
    document_id, corpus_id
)  # NamedTuple — fields stay named for IDE/grep

The NamedTuple comment describes _DocTextResult being a NamedTuple, which a reader can see from the type definition. It doesn't explain why the unpacking is intentionally kept verbose rather than positional — drop it or replace it with a note about why the non-inline form was chosen.


Tests

Test coverage is genuinely comprehensive — chunking dedup, offset remapping, OOB guard, all eight entity categories, dry_run, min_score, char-range scoping, unconfigured service, unsupported file type, corpus linkage, and registry metadata. The fixtures-based approach avoids live service dependencies. No issues here.

One style note: test_privacy_filter_client.py mixes pytest.raises (which assumes pytest test collection) with django.test.TestCase subclasses. In Django's test runner (not pytest), pytest.raises won't be available as a context manager. If the test suite is run via python manage.py test, these will fail with AttributeError. Either use self.assertRaises consistently or annotate that these tests require the pytest runner.


Summary

Area Verdict
Core chunking / dedup logic ✅ Solid
Async plumbing ✅ Correct
Test coverage ✅ Comprehensive
requires_approval=True on tool ✅ Appropriate
Mixed-corpus label duplication ⚠️ Needs design decision
Production empty-key default ⚠️ Needs explicit acknowledgement of behavior
Sequential chunks (performance) ℹ️ Acceptable for now, worth a TODO
pytest.raises in TestCase 🐛 Will break under manage.py test

@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown

Test review comment.

@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown

Code Review: PR 1624 - PII Scan and Auto-Annotate Agent Tool

Overview

Well-structured PR overall: clean async/sync separation, solid overlap-dedup logic, thoughtful edge-case handling, good test coverage. A few things worth addressing before merge.

Issue 1 - Performance: N+1 saves inside the atomic block

In pii.py _persist_annotations_sync, ann.save() is called once per detection inside transaction.atomic(). For a document with hundreds of PII hits this becomes N individual INSERTs. Prefer Annotation.objects.bulk_create() with a pre-built list. bulk_create returns created objects with PKs populated in Django >= 4.1, so the (ann.pk, det) pairs callers rely on are still available.

Issue 2 - Security: production service can start with an empty API key

production.yml now uses PRIVACY_FILTER_API_KEY with a fail-open fallback. If an operator forgets to set the variable, the container starts with no key - any caller on the docker bridge can POST to it unauthenticated. There is no ports mapping so external exposure is low, but worth documenting. Consider a Django system-check or startup log warning when PRIVACY_FILTER_URL is set but PRIVACY_FILTER_API_KEY is empty.

Issue 3 - Label pre-creation race: comment could point to the guarantee

The comment in _persist_annotations_sync correctly identifies the race and mentions that ensure_label_and_labelset uses a check-then-create pattern, but does not state what prevents a duplicate-key error when two callers collide simultaneously. A one-liner pointing to the DB-level uniqueness constraint or the get_or_create call inside that method would save future readers from tracing the call chain.

Issue 4 - Minor: env() vs env.str() inconsistency

New settings use bare env(), but every other string setting in base.py uses env.str(). Prefer env.str() for consistency.

Issue 5 - Missing test: corpus_action_id propagation

None of the 18 tests pass a non-None corpus_action_id and verify it appears on the created annotation. A single round-trip test would close this gap.

Non-blocking observations

  • No retry logic for per-chunk HTTP failures: acceptable since the agent can retry the tool call, but worth a docstring note so callers know a retry re-scans the whole document from scratch.
  • Unrelated type-annotation cleanups (multimodal_microservice.py, sent_transformer_microservice.py, docxodus_parser.py, users/tasks.py) are all correct but obscure the diff for future bisects.
  • Chunk dedup via (start, end, group) key + keep-highest-score is the right approach and is well-tested.
  • _iter_chunk_starts boundary handling is correct; a direct unit test for the exact-CHUNK_SIZE and CHUNK_SIZE+1 cases would add confidence.

Summary

Two items worth addressing before merge: the N+1 save() loop (performance) and the unauthenticated-service risk (security). The env.str() inconsistency and missing corpus_action_id test are minor. The async architecture, error-to-RuntimeError wrapping, dry-run mode, and the pre-creation-outside-atomic label pattern are all solid.

JSv4 added 2 commits May 12, 2026 22:24
…ace comment, cover corpus_action_id propagation

- _privacy_filter_client emits a one-time warning when PRIVACY_FILTER_URL
  is set but PRIVACY_FILTER_API_KEY is empty so misconfigurations show up
  in logs instead of silently shipping with an unauthenticated service.
- Rewrite the label pre-creation race comment in _persist_annotations_sync
  to explain *why* doing get-or-create outside the atomic block is the
  correct trade-off (no DB-level uniqueness on AnnotationLabel; the
  pre-creation isolates a rare duplicate to the labels rather than
  rolling back the whole annotation batch).
- New test_corpus_action_id_propagates_to_created_annotations covers the
  round-trip from CorpusAction.id → kwarg → Annotation.corpus_action_id
  so future regressions in the propagation path don't slip through.
"requests will be sent unauthenticated. Set PRIVACY_FILTER_API_KEY "
"to gate access to the privacy-filter service."
)
_warned_about_missing_api_key = True
@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown

Code Review: Add PII scan and auto-annotate agent tool

Overall, this is a well-structured PR with solid test coverage, clear separation of concerns, and good documentation. The chunking/dedup logic in the HTTP client is particularly clean. A few issues worth addressing before merge:


Bugs / Correctness

1. ann.save() called in a loop — should use bulk_create
_persist_annotations_sync (pii.py) calls ann.save() inside a for det in detections loop. A document with 50+ PII detections fires 50 individual INSERTs inside a single transaction.atomic(). Replace with Annotation.objects.bulk_create(anns) and collect the resulting PKs from the returned instances. This also avoids the possibility of ann.pk being None if the ORM somehow fails silently (it won't, but the type is cleaner).

# Instead of:
ann.save()
persisted.append((ann.pk, det))

# Consider:
anns_to_create = [...]
created = Annotation.objects.bulk_create(anns_to_create)
persisted = list(zip([a.pk for a in created], filtered_dets))

2. _iter_chunk_starts may double-emit the final chunk
When pos + CHUNK_SIZE == total_len exactly, the while pos + CHUNK_SIZE < total_len loop exits without appending that pos, then the starts.append(pos) after the loop appends it correctly. That case is fine. However consider total_len = CHUNK_SIZE + 1 with the default constants: loop runs once, starts = [0], then appends CHUNK_SIZE - CHUNK_OVERLAP. The final chunk starts at CHUNK_SIZE - 500 and covers CHUNK_SIZE chars — overlapping extensively with chunk 1. This is expected behavior, but worth adding a comment that the last chunk may overlap more than CHUNK_OVERLAP chars with the previous one.


Security

3. Production PRIVACY_FILTER_API_KEY changed from required to optional without a compensating control
The comment explains that ${...:?} aborted the entire compose stack when the var was unset, which contradicts required: false. The fix (changing to ${PRIVACY_FILTER_API_KEY:-}) is pragmatically correct for opt-in deployment, but the net result is that production can silently spin up an unauthenticated privacy-filter service on the internal bridge. The existing warning in _privacy_filter_client.py helps, but only if the service is actually called. Consider adding a deploy-time note to the deployment docs or a Django system check that logs WARNING at startup if PRIVACY_FILTER_URL is set but PRIVACY_FILTER_API_KEY is empty.

4. No explicit permission guard in _load_doc_text_sync
The function verifies document↔corpus linkage but does not check that creator_id has write permission on the corpus. The requires_write_permission=True flag on the ToolDefinition is the intended guard, but that only fires when the tool is invoked through the agent framework. If ascan_and_annotate_pii is called directly (e.g. from a Celery task, a test, or future code), the permission gate is bypassed. Adding corpus.objects.visible_to_user(...) or a user_has_permission_for_obj call inside _load_doc_text_sync would make the function safe to call from any context.


Code Quality

5. Module-level mutable global _warned_about_missing_api_key not reset between tests
_privacy_filter_client.py uses a process-level boolean to suppress duplicate log warnings. Tests that exercise the empty-key path in separate test methods share this global; the second test to trigger the path will silently skip the warning. If a future test asserts the warning is emitted, it will flake depending on test execution order. Either reset it in test setUp/tearDown or use Python's logging.captureWarnings / assertLogs pattern that doesn't depend on the guard.

6. ensure_label_and_labelset comment in _persist_annotations_sync is misleading
The comment says the method uses a "check-then-create with no DB-level uniqueness constraint" and warns of a race. Looking at the actual implementation, ensure_label_and_labelset does wrap the check+create in transaction.atomic() (model line 1318). In READ COMMITTED (the PostgreSQL default) that doesn't prevent the race across two concurrent transactions, so the concern is valid — but the comment should note that the atomic block exists and explain why it's still not sufficient (two transactions both pass the filter().first() check before either commits).

7. Noisy type-annotation-only diffs in unrelated files
multimodal_microservice.py, sent_transformer_microservice.py, docxodus_parser.py, and users/tasks.py each add an intermediate typed variable (text_payload, image_payload, payload, request_data) before passing them to json=. These are pure type annotation improvements with zero behavioral change, which is fine — but they touch unrelated files and inflate the diff. If these are intentional mypy fixes, they'd be cleaner as a separate PR or a separate commit tagged chore: improve type annotations.


Test Coverage Gaps

8. No test for the empty-API-key warning path
The _warned_about_missing_api_key logic (emitting a logger.warning once per process) has no test coverage. A test using self.assertLogs(level="WARNING") would confirm the message is emitted on the first call and suppressed on subsequent calls.

9. No test for the concurrent-label-creation race
The comment acknowledges that two concurrent scans on the same fresh corpus can create duplicate labels. Adding at least a comment in the test suite acknowledging this known limitation (and pointing at the accepted-risk comment in the source) would document the design decision explicitly.

10. test_service_unconfigured_raises_runtime_error is not fully isolated
This test uses @override_settings(PRIVACY_FILTER_URL="") at the method level but calls _load_doc_text_sync first (which reads the document from the database). If the document setup in setUp fails, the test will raise before reaching the client and the error message will be unrelated. This is a minor fragility; it could be improved by patching _load_doc_text_sync to return a fixed _DocTextResult so the test strictly exercises the client path.


Minor Nits

  • _iter_chunk_starts is annotated as returning list[int] but is used as an iterable in async with ... for chunk_start in _iter_chunk_starts(...). Returning a Generator or Iterator would avoid materializing the full list in memory (minor for typical document sizes, but worth noting for consistency with the "no unnecessary allocations" principle).
  • The ENTITY_GROUP_LABELS dict uses "private_email" as the key but the PR description and tool description both say "private_email". The naming inconsistency with the service's documented entity name (email vs private_email) should be confirmed against the actual privacy-filter API schema to avoid silent misses.
  • local.yml healthcheck uses python -c "..." with escaped inner quotes. This is fragile across shells — consider using curl -sf http://localhost:8000/health if curl is available in the image, or moving to a dedicated health script.

Summary

The core logic (chunked HTTP client, offset remapping, overlap dedup, PDF vs text annotation dispatch) is sound and well-tested. The main items to address before merge:

  1. Replace per-annotation save() with bulk_create (performance + correctness)
  2. Add a permission check inside _load_doc_text_sync so the function is safe outside the tool framework (security)
  3. Confirm private_email matches the actual privacy-filter API's entity group name (correctness)

JSv4 added 2 commits May 12, 2026 22:47
…ote, empty-API-key warning test

- _iter_chunk_starts: document the case where total_len lands just past a
  chunk boundary and the final chunk overlaps by more than CHUNK_OVERLAP
  (de-dup on (start, end, group) keeps the over-coverage harmless).
- _persist_annotations_sync: clarify that ensure_label_and_labelset already
  wraps its check-then-create in transaction.atomic(), but READ COMMITTED
  isolation does not prevent the duplicate-label race because there is no
  DB-level uniqueness on AnnotationLabel(text, label_type). Pre-creating
  outside the inner atomic block isolates the rare duplicate to the label
  table so the larger Annotation insert batch still commits.
- _load_doc_text_sync: add docstring note explaining the helper deliberately
  has no in-line authorization check; the agent tool framework gates writes
  via requires_write_permission=True and the function is module-private
  precisely because direct callers would bypass that gate.
- Add test_empty_api_key_logs_warning_once covering the suppression-after-
  first-emit path on _warned_about_missing_api_key, with explicit reset in
  try/finally so the test is order-independent.
@claude

claude Bot commented May 13, 2026

Copy link
Copy Markdown

Code Review — PR #1624: PII Scan & Auto-Annotate Agent Tool

Overview

This PR adds a well-architected scan_and_annotate_pii agent tool that integrates a privacy_filter microservice for document PII detection. The chunking logic, overlap deduplication, and offset remapping are all correctly implemented, and the 18-test suite covers the important paths thoroughly. A few issues are worth addressing before merge.


Issues

1. httpx dependency not added to requirements

_privacy_filter_client.py does import httpx but no requirements file is updated in the diff. If httpx isn't already a transitive dependency, this will fail at runtime. Worth verifying (or adding to requirements/base.txt).

2. Production privacy_filter service can start with empty API_KEYS

production.yml uses API_KEYS: ${PRIVACY_FILTER_API_KEY:-}, which silently resolves to an empty string if the operator forgets to set PRIVACY_FILTER_API_KEY. This is noted in the comment as intentional ("opt-in service"), but the consequence is an unauthenticated PII detection service running on the Docker bridge. The CHANGELOG originally called for a fail-closed ${...:?} syntax. Consider at least surfacing a startup log warning or documenting the risk in the production env template.

3. Individual ann.save() inside transaction.atomic() — performance concern

_persist_annotations_sync (pii.py:516–572) calls ann.save() per detection inside a single atomic() block. For a document with 200+ PII hits this is 200+ round-trips inside one transaction. Annotation.objects.bulk_create(annotations) (with batch_size) would be significantly faster and keeps the atomic guarantee. Note: bulk_create skips post_save signals — verify whether downstream signal handlers attach to Annotation.save and whether they need to fire here.

4. SpanAnnotation / TextSpan defined in one branch, used inside a loop in another

# pii.py lines 477-481
if file_type == "application/pdf":
    from plasmapdf.models.types import SpanAnnotation, TextSpan
    label_type_const = TOKEN_LABEL
else:
    label_type_const = SPAN_LABEL

with transaction.atomic():
    for det in detections:
        ...
        if file_type == "application/pdf":
            span = TextSpan(...)     # SpanAnnotation/TextSpan only defined in the outer `if`

Python scoping makes this work, but mypy / pyright will report SpanAnnotation and TextSpan as possibly-undefined in the inner branch. Move both imports to module-level (or at least to function-level top) to eliminate the analysis noise and make it clear the names are always available when the inner if is true.

5. CHUNK_SIZE / CHUNK_OVERLAP aliased re-exports from a private module

_privacy_filter_client.py imports the constants under shorter aliases (CHUNK_OVERLAP, CHUNK_SIZE) and test_privacy_filter_client.py imports them directly from the private module. This leaks the alias into the public module surface of _privacy_filter_client. The tests should import directly from opencontractserver.constants.document_processing using the canonical names, keeping the private module's namespace clean.

6. ensure_label_and_labelset duplicate label race acknowledged but not tested

The inline comment (lines 486–498) correctly explains why two concurrent scans can create duplicate AnnotationLabel rows under READ COMMITTED. This is an accepted trade-off, but there's no test that exercises the scenario even with a mock. A test that calls _persist_annotations_sync (via _db_sync_to_async) twice concurrently for the same entity group would document the "accepted duplicate" behaviour explicitly and catch any future regression if the label model gets a uniqueness constraint.


Minor suggestions

  • by_group counter (pii.py:660–662): collections.Counter(d["entity_group"] for d in detections) is idiomatic and avoids the manual .get(..., 0) dance.

  • _iter_chunk_starts return type annotation says -> list[int] but could be -> Iterator[int] with a yield-based implementation, which avoids building the list. For the chunk counts involved (a handful) this doesn't matter, but is slightly cleaner.

  • _load_doc_text_sync auth comment (lines 406–413): The note that authorization is enforced by the framework is good. Consider adding a brief reference to where that gate is (ToolDefinition.requires_write_permission checked in PydanticAIToolWrapper) so a future reader doesn't have to grep.

  • local.yml API_KEYS value is dev-only-not-secret: The comment says "override before deploying" but since there's no ports mapping there's no external exposure. Fine for local dev, just ensure the PRIVACY_FILTER_API_KEY env var in .envs/.local/.django matches so the Django client can authenticate against the local service (this might already be wired up but isn't shown in the diff).


What's done well

  • Async-first design with _db_sync_to_async wrapping the ORM helpers — correct per the project's tool authoring rules.
  • requires_approval=True + requires_write_permission=True on the registry entry — right security posture for a write tool.
  • Overlap dedup logic (seen dict, keep highest-score) is correct and the multi-chunk test case validates it.
  • dry_run path returns detections without writing — good for agent-side previewing.
  • start_char/end_char offset remapping is correctly handled and tested.
  • The global _warned_about_missing_api_key flag suppresses log flooding, and the test correctly resets it to be order-independent.

Overall this is solid work. The httpx dependency and the production empty-key scenario are the two items most worth addressing before merge; the rest are improvements rather than blockers.

@JSv4
JSv4 merged commit 11ca122 into main May 13, 2026
17 checks passed
@JSv4
JSv4 deleted the feature/pii-scan-and-annotate-tool branch May 13, 2026 05:39
JSv4 added a commit that referenced this pull request May 15, 2026
* Address PR #1624 review follow-ups (closes #1638)

Six items raised in #1638 against the PII scan agent tool that landed in
PR #1624:

- _persist_annotations_sync now uses Annotation.objects.bulk_create with
  manual transaction.on_commit embedding-task queueing — mirroring the
  established pattern in annotations/models.py — so a document with 200+
  PII hits no longer pays 200+ round-trips inside the atomic block. PDF
  TOKEN_LABEL JSON is explicitly compact_annotation_json()'d before
  bulk_create because Annotation.save()'s auto-compaction is skipped on
  the bulk path.

- plasmapdf.models.types imports hoisted to module scope in pii.py so
  static analyzers stop flagging SpanAnnotation / TextSpan as
  possibly-undefined inside the for-loop's PDF branch.

- _privacy_filter_client.py no longer re-exports CHUNK_SIZE /
  CHUNK_OVERLAP as aliases. Tests import the canonical
  PRIVACY_FILTER_CHUNK_SIZE / PRIVACY_FILTER_CHUNK_OVERLAP names
  directly from opencontractserver.constants.document_processing.

- New agents.W001 Django system check warns on startup when
  PRIVACY_FILTER_URL is set but PRIVACY_FILTER_API_KEY is empty.
  Production env template grows a Privacy Filter section calling out
  the same risk. The existing one-shot logger.warning in
  _privacy_filter_client.py on first request stays in place.

- New test_persist_annotations_sync_duplicate_label_race regression
  test documents the accepted-duplicate behavior of
  Corpus.ensure_label_and_labelset under PostgreSQL READ COMMITTED
  (no DB-level uniqueness on AnnotationLabel(text, label_type)).
  Simulates the post-race outcome via patch.object on
  ensure_label_and_labelset and asserts duplicate labels exist while
  both annotation inserts still succeed.

- httpx is already declared in requirements/base.txt (line 13) — no
  change needed, item closed via verification.

* Address PR #1642 review: fix mypy lambda + pin unknown-group filter test

* Address PR review: fix pytest failures, add agents.W001 tests, refactor

- ScanAndAnnotateKnobsTests (3 failing tests) and sibling persist-side
  PII tool test classes pin serialized_rollback=True. _persist_annotations_sync
  registers transaction.on_commit Celery enqueues against
  calculate_embedding_for_annotation_text, which under
  CELERY_TASK_ALWAYS_EAGER=True runs the eager task body once the atomic
  block commits. The eager task reads the default embedder path from the
  PipelineSettings singleton seeded by migration 0031. TransactionTestCase
  truncates *all* tables including documents_pipelinesettings between
  tests by default, so a prior class on the same pytest-xdist worker can
  leave us with no default embedder and the eager retry chain raises out
  of the on_commit callback. serialized_rollback=True restores the
  migration-seeded row after each test's truncation. Same fix applied to
  every PII test class that drives _persist_annotations_sync.

- agents.W001 check moved to Tags.security so manage.py check
  --tag security surfaces it alongside the rest of the security family
  (PR review feedback).

- _queue_embed closure factory lifted to module scope (PR review
  feedback) so it isn't redefined on every _persist_annotations_sync
  call. Added defensive assert that bulk_create populated ann.pk per
  PostgreSQL RETURNING — the test suite runs against PostgreSQL so the
  assert never fires in practice, but surfaces a clearer failure mode
  than a None-keyed task if the backend ever changes.

- New test_agents_system_checks.py covers the four PRIVACY_FILTER_URL ×
  PRIVACY_FILTER_API_KEY truth-table cases plus a whitespace-treated-as-
  unset case and a registered-with-security-tag invariant.

* Mock _queue_embed in PII tests instead of serialized_rollback

Replaces the serialized_rollback=True approach (which produced
django_content_type UniqueViolation errors at TransactionTestCase setup
when multiple sibling classes both restored the migration-seeded
fixture) with a surgical patch of the module-level _queue_embed factory
to a no-op callback. The patch lives in _PiiPersistEmbeddingNoopMixin
mixed into every persist-side test class; the two later persist-side
classes (PersistAnnotationsLabelRaceTests,
PersistAnnotationsUnknownGroupTests) inline the patch directly so the
patch lifecycle stays obvious next to the rest of their setUp.

Rationale spelled out in the mixin's docstring. Tests in this module
don't exercise embedding behaviour at all, so stubbing the on-commit
callback is the cleanest way to avoid the eager-celery + missing
PipelineSettings cross-test ordering bug.

* Fix mypy: broaden requests headers types to dict[str, str | bytes]

types-requests upgrade made requests.post/get expect MutableMapping[str, str | bytes],
which is invariant in values, so dict[str, str] no longer matches. Broaden header
annotations and maybe_add_cloud_run_auth() signature accordingly.

* Address PR review: replace assert in production path, apply mixin to race + unknown-group tests

- pii._persist_annotations_sync: raise RuntimeError instead of assert when bulk_create returns rows without pk, so the invariant survives interpreters launched with -O.
- pii._queue_embed docstring: drop PR-number reference per CLAUDE.md.
- test_pii_scan_tool: apply _PiiPersistEmbeddingNoopMixin to PersistAnnotationsLabelRaceTests and PersistAnnotationsUnknownGroupTests (DRY); replace fragile slice-based new_labels query with snapshot-and-exclude idiom.

* Apply black formatting to test_pii_scan_tool.py

* Address review: hoist bulk batch size to constant, narrow JSON compaction exception, document race_ensure mock signature coupling

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant