From 1c7c0e3cc118cadd3a063d58c9d69bc9ca36b466 Mon Sep 17 00:00:00 2001 From: Degentle12 Date: Thu, 20 Aug 2026 22:33:06 +0000 Subject: [PATCH] fix(ai-service): enforce PII scrubbing on the humanitarian verification path The PII scrubber was only reachable as an opt-in /anonymize endpoint, so /v1/ai/humanitarian/verify transmitted raw recipient evidence (names, locations, phones, emails, IDs) to OpenAI/Groq unredacted. Wire the scrubber in as a mandatory preprocessing stage before prompt construction: - Scrub aid_claim, supporting_evidence, and string-valued context_factors before any provider call; only masked tokens reach the provider. - Fail closed: when PII_SCRUBBING_ENABLED=false or scrubbing itself fails, the request is rejected and no provider call is made. - Expose a pii_scrubbing block (applied/anonymized/pii_summary) in the verification response so callers can tell scrubbed input from raw; raw text stays on the backend side only. - Persist aggregate-only scrub metadata (counts + SHA-256 fingerprint, never text) to the pii_decisions store when enabled. - Document the scrubbing posture and residual risk in the AI service README. Closes #430 --- app/ai-service/README.md | 34 +++ app/ai-service/config.py | 10 + app/ai-service/schemas/humanitarian.py | 3 +- .../schemas/humanitarian_verification_v2.py | 1 + .../services/humanitarian_verification.py | 172 ++++++++++++- app/ai-service/services/pii_scrubber.py | 17 +- .../tests/test_humanitarian_pii_scrubbing.py | 227 ++++++++++++++++++ .../tests/test_humanitarian_verification.py | 23 +- 8 files changed, 469 insertions(+), 18 deletions(-) create mode 100644 app/ai-service/tests/test_humanitarian_pii_scrubbing.py diff --git a/app/ai-service/README.md b/app/ai-service/README.md index a788d83f..ea705369 100644 --- a/app/ai-service/README.md +++ b/app/ai-service/README.md @@ -32,6 +32,8 @@ The service starts at `http://localhost:8000`. Interactive API documentation is | `BACKEND_WEBHOOK_URL` | `http://localhost:3001/ai/webhook` | Backend notification endpoint | | `MAX_REQUEST_BODY_BYTES` | `10485760` (10 MiB) | Maximum HTTP request body size; oversized requests are rejected with HTTP 413 to prevent memory-exhaustion DoS. Set to `0` to disable (not recommended in production). | | `REQUEST_BODY_BYPASS_PATHS` | _(empty)_ | Comma-separated path entries that bypass body-size limiting. Entries without a trailing `'/'` must match the path exactly; entries with a trailing `'/'` (e.g. `/hooks/`) match any path with that prefix. The default bypass list (`/health`, `/`, `/ai/metrics`, `/docs`, `/redoc`, `/openapi.json`) is always merged in. | +| `PII_SCRUBBING_ENABLED` | `true` | Master switch for the mandatory PII preprocessing stage on `/v1/ai/humanitarian/verify`. When disabled the service **fails closed**: verification requests are rejected rather than sending unredacted evidence to an external LLM provider. | +| `PII_DECISIONS_ENABLED` | `false` | When truthy, every successful scrubbing event (the `/v1/ai/anonymize` endpoint and the humanitarian verification pipeline) writes aggregate audit metadata (counts + fingerprint only, never text) to the PII decisions store. | ## Core services @@ -116,6 +118,38 @@ Analyzes claim metadata batches using Local Outlier Factor and flags anomalous p --- +## PII scrubbing posture + +PII anonymization is enforced as a **mandatory preprocessing stage**, not an +opt-in helper. Before any prompt is built for an external LLM provider, the +pipeline scrubs `aid_claim`, every `supporting_evidence` entry, and +string-valued `context_factors` using the same detector that backs +`/v1/ai/anonymize`. + +| Endpoint | Scrubs PII before external processing | Notes | +|---|---|---| +| `POST /v1/ai/humanitarian/verify` | ✅ Yes | Fail-closed: if scrubbing is disabled (`PII_SCRUBBING_ENABLED=false`) or fails, the request is rejected with `success=false` and no provider call is made. The response includes a `pii_scrubbing` block (`applied`, `anonymized`, `pii_summary`); the raw text is never returned and remains only on the backend (human-review) side. | +| `POST /v1/ai/anonymize` | ✅ Yes | Explicit scrubbing endpoint; the caller receives the anonymized text and aggregate summary. | +| `POST /ai/ocr` | ❌ No | Local document extraction (Tesseract); no external LLM call is made from this path. | +| `POST /v1/ai/fraud/detect` | ❌ No | Local statistical anomaly detection (LOF); no external LLM call is made from this path. | +| `POST /ai/proof-of-life` | ❌ No | Local face/liveness analysis; no external LLM call is made from this path. | + +**Residual risk.** Detection relies on a regex set plus a spaCy `blank` +entity ruler, so precision is imperfect: generic capitalized phrases can be +over-redacted, and unusual identifier formats (e.g. non-Nigerian ID numbers, +foreign phone formats) can pass through undetected. Treat scrubbing as a +privacy-control layer, not a guarantee; recipients should be advised that +names and locations embedded in evidence are material to verification and +may affect the verdict. Do not rely on scrubbed output to fully de-identify +free text. + +**Audit.** When `PII_DECISIONS_ENABLED=true`, each scrubbing event writes an +aggregate record (entity counts, token counts, a non-reversible SHA-256 +fingerprint, model version) to the SQLite `pii_decisions` store. Raw or +anonymized text is never persisted. + +--- + ## Versioned API All routes are available under versioned and legacy paths during the transition period. diff --git a/app/ai-service/config.py b/app/ai-service/config.py index 090627a4..2d8e80af 100644 --- a/app/ai-service/config.py +++ b/app/ai-service/config.py @@ -54,6 +54,11 @@ class Settings(BaseSettings): survives before the periodic sweeper removes it. Default: 30. PII_DECISIONS_SWEEP_INTERVAL_SECONDS: How often the in-process retention sweep runs. Default: 3600 (1 hour). + PII_SCRUBBING_ENABLED: Master switch for the mandatory PII + preprocessing stage on the humanitarian verification pipeline. + When disabled the service fails closed: verification requests + are rejected rather than sending unredacted evidence to an + external LLM provider. Default: true. """ # API Keys @@ -102,6 +107,11 @@ class Settings(BaseSettings): # the store is opt-in for tests; the runtime behaviour of # /v1/ai/anonymize is unchanged when disabled. pii_decisions_enabled: bool = False + + # PII scrubbing (Issue #430): mandatory preprocessing stage for the + # humanitarian verification pipeline. Fails closed when disabled so raw + # recipient text is never sent to an external LLM provider. + pii_scrubbing_enabled: bool = True pii_decisions_db_path: str = "./data/pii_decisions.db" pii_decisions_retention_days: int = 30 pii_decisions_sweep_interval_seconds: int = 3600 diff --git a/app/ai-service/schemas/humanitarian.py b/app/ai-service/schemas/humanitarian.py index 3fbc1d04..9b37698f 100644 --- a/app/ai-service/schemas/humanitarian.py +++ b/app/ai-service/schemas/humanitarian.py @@ -17,4 +17,5 @@ class HumanitarianVerificationResponse(BaseModel): prompt_variant: Optional[str] = None verification: Optional[Dict[str, Any]] = None error: Optional[str] = None - model_version: Optional[str] = None \ No newline at end of file + model_version: Optional[str] = None + pii_scrubbing: Optional[Dict[str, Any]] = None \ No newline at end of file diff --git a/app/ai-service/schemas/humanitarian_verification_v2.py b/app/ai-service/schemas/humanitarian_verification_v2.py index fd98f472..8f8f6bfd 100644 --- a/app/ai-service/schemas/humanitarian_verification_v2.py +++ b/app/ai-service/schemas/humanitarian_verification_v2.py @@ -21,4 +21,5 @@ class HumanitarianVerificationResponseV2(BaseModel): error: Optional[str] = None model_version: Optional[str] = None stamp: Optional[Dict[str, str]] = None + pii_scrubbing: Optional[Dict[str, Any]] = None diff --git a/app/ai-service/services/humanitarian_verification.py b/app/ai-service/services/humanitarian_verification.py index 65eede6f..207938e4 100644 --- a/app/ai-service/services/humanitarian_verification.py +++ b/app/ai-service/services/humanitarian_verification.py @@ -1,8 +1,9 @@ """Humanitarian claim verification service with model/provider fallbacks.""" +import hashlib import json import logging -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import time import metrics @@ -11,6 +12,7 @@ from config import settings from services.humanitarian_prompt import HumanitarianPromptEngine from services.circuit_breaker import CircuitBreaker +from services.pii_scrubber import PIIScrubberService from services.test_provider import TestProvider from exceptions import AIServiceError @@ -20,9 +22,14 @@ class HumanitarianVerificationService: """Runs humanitarian verification against configured LLM providers.""" + _PII_SUMMARY_KEYS = ("names", "locations", "dates", "emails", "phones", "ids", "total") + def __init__(self): self.prompt_engine = HumanitarianPromptEngine() self.test_provider = TestProvider() + # Issue #430: the scrubber is a mandatory preprocessing stage, not an + # optional endpoint. Raw recipient text must never reach a provider. + self.scrubber = PIIScrubberService() self.breakers = { "openai": CircuitBreaker( name="openai", @@ -49,15 +56,22 @@ def verify_claim( evidence = supporting_evidence or [] context = context_factors or {} + # Issue #430: mandatory PII preprocessing before any provider + # call. Only the scrubbed inputs ever reach prompt construction, + # so raw recipient text cannot leave the trust boundary. + scrubbed_claim, scrubbed_evidence, scrubbed_context, pii_summary, pii_token_counts = ( + self._scrub_inputs(aid_claim, evidence, context) + ) + primary_prompt = self.prompt_engine.build_primary_prompt( - aid_claim=aid_claim, - supporting_evidence=evidence, - context_factors=context, + aid_claim=scrubbed_claim, + supporting_evidence=scrubbed_evidence, + context_factors=scrubbed_context, ) fallback_prompt = self.prompt_engine.build_fallback_prompt( - aid_claim=aid_claim, - supporting_evidence=evidence, - context_factors=context, + aid_claim=scrubbed_claim, + supporting_evidence=scrubbed_evidence, + context_factors=scrubbed_context, ) providers = self._provider_attempt_order(provider_preference) @@ -92,6 +106,16 @@ def verify_claim( parsed = parse_verification_response(provider, raw_content) if breaker: breaker.record_success() + # Aggregate-only audit record (counts + fingerprint, + # never text) so the redaction decision is traceable. + self._record_pii_decision( + raw_claim=aid_claim, + raw_evidence=evidence, + raw_context=context, + summary=pii_summary, + token_counts=pii_token_counts, + model=model, + ) return { "provider": provider, "model": model, @@ -102,7 +126,12 @@ def verify_claim( "provider": provider, "model": model, "prompt_variant": prompt_variant, - } + }, + "pii_scrubbing": { + "applied": True, + "anonymized": pii_summary["total"] > 0, + "pii_summary": pii_summary, + }, } except Exception as exc: if breaker: @@ -116,6 +145,133 @@ def verify_claim( latency = time.time() - start_time metrics.PIPELINE_STEP_LATENCY.labels(step_name='verify').observe(latency) + def _scrub_inputs( + self, + aid_claim: str, + evidence: List[str], + context: Dict[str, Any], + ) -> Tuple[str, List[str], Dict[str, Any], Dict[str, int], Dict[str, int]]: + """Fail-closed PII preprocessing stage for the verification pipeline. + + Masks names/locations/dates/emails/phones/IDs in the claim, each + evidence entry, and string-valued context factors *before* any prompt + is built, so raw recipient text can never reach an external LLM + provider. Raises when scrubbing is disabled or fails rather than + silently forwarding unredacted text (Issue #430). + + Returns the scrubbed inputs plus aggregated ``pii_summary`` and + ``token_counts`` for the response envelope and the audit store. + """ + if not settings.pii_scrubbing_enabled: + raise RuntimeError( + "PII scrubbing is disabled (PII_SCRUBBING_ENABLED=false); " + "refusing to send unredacted evidence to an external LLM provider" + ) + + summary: Dict[str, int] = {key: 0 for key in self._PII_SUMMARY_KEYS} + token_counts: Dict[str, int] = {} + + try: + scrubbed_claim, field_summary, field_tokens = self._scrub_field(aid_claim) + self._merge_scrub_stats(summary, token_counts, field_summary, field_tokens) + + scrubbed_evidence: List[str] = [] + for entry in evidence: + scrubbed, field_summary, field_tokens = self._scrub_field(entry) + scrubbed_evidence.append(scrubbed) + self._merge_scrub_stats(summary, token_counts, field_summary, field_tokens) + + scrubbed_context: Dict[str, Any] = {} + for key, value in context.items(): + if isinstance(value, str): + scrubbed, field_summary, field_tokens = self._scrub_field(value) + scrubbed_context[key] = scrubbed + self._merge_scrub_stats(summary, token_counts, field_summary, field_tokens) + else: + scrubbed_context[key] = value + except Exception as exc: + raise RuntimeError( + f"PII scrubbing failed ({exc}); refusing to send unredacted " + "evidence to an external LLM provider" + ) from exc + + return scrubbed_claim, scrubbed_evidence, scrubbed_context, summary, token_counts + + def _scrub_field(self, text: str) -> Tuple[str, Dict[str, int], Dict[str, int]]: + """Scrub one free-text field; returns (masked, summary, token_counts).""" + result = self.scrubber.scrub_text(text) + return ( + result["anonymized_text"], + result["pii_summary"], + result["token_counts"], + ) + + def _merge_scrub_stats( + self, + summary: Dict[str, int], + token_counts: Dict[str, int], + field_summary: Dict[str, int], + field_tokens: Dict[str, int], + ) -> None: + """Aggregate one field's scrub summary/token counts into the totals.""" + for key in self._PII_SUMMARY_KEYS: + summary[key] += field_summary.get(key, 0) + for token, count in (field_tokens or {}).items(): + token_counts[token] = token_counts.get(token, 0) + count + + def _record_pii_decision( + self, + raw_claim: str, + raw_evidence: List[str], + raw_context: Dict[str, Any], + summary: Dict[str, int], + token_counts: Dict[str, int], + model: str, + ) -> None: + """Persist aggregate scrub metadata (never text) when enabled. + + Mirrors the /v1/ai/anonymize audit path: aggregate counts plus a + non-reversible SHA-256 fingerprint of the concatenated inputs. + Failures are logged, never raised, so an audit hiccup can't break a + verification. + """ + try: + if not settings.pii_decisions_enabled: + return + from persistence.pii_decisions import ( + PIIDecisionRecord, + PIIDecisionStore, + new_record_id, + ) + + raw_text = ( + raw_claim + + "\n" + + "\n".join(raw_evidence) + + "\n" + + json.dumps(raw_context, sort_keys=True) + ) + record = PIIDecisionRecord( + id=new_record_id(), + created_at=time.time(), + original_length=len(raw_text), + pii_summary=summary, + token_counts=token_counts, + text_fingerprint=hashlib.sha256(raw_text.encode("utf-8")).hexdigest(), + model_version=model, + ) + PIIDecisionStore(settings.pii_decisions_db_path).save_decision( + record, + settings.pii_decisions_retention_days, + ) + logger.info( + "stored pii_decision id=%s total=%d", + record.id, + summary.get("total", 0), + ) + except Exception as exc: # pragma: no cover - defensive + logger.error("pii_decision persistence failed: %s", exc) + def _provider_attempt_order(self, provider_preference: str) -> List[str]: available: List[str] = [] if settings.test_provider_mode: diff --git a/app/ai-service/services/pii_scrubber.py b/app/ai-service/services/pii_scrubber.py index f69297e7..a5e64479 100644 --- a/app/ai-service/services/pii_scrubber.py +++ b/app/ai-service/services/pii_scrubber.py @@ -78,10 +78,25 @@ def __init__(self): metrics.PII_MODEL_VERSION.labels(version=PII_MODEL_VERSION).set(1) def anonymize(self, text: str) -> Dict[str, object]: - """Return privacy-preserving anonymized text and summary metadata.""" + """Return privacy-preserving anonymized text and summary metadata. + + When ``test_provider_mode`` is enabled the result is served from + fixture files so staging/testnet deployments can exercise the + contract without running the detector; otherwise the local detector + runs via :meth:`scrub_text`. + """ if settings.test_provider_mode: return self.test_provider.get_response("anonymize", {"text": text}) + return self.scrub_text(text) + + def scrub_text(self, text: str) -> Dict[str, object]: + """Run the local PII detector and return masked text + summary metadata. + Unlike :meth:`anonymize`, this always executes the local regex and + spaCy detector and never routes through the test provider, so + hot-path consumers (e.g. the humanitarian verification pipeline) + get deterministic masking even when ``test_provider_mode`` is set. + """ start_time = time.time() try: if not text: diff --git a/app/ai-service/tests/test_humanitarian_pii_scrubbing.py b/app/ai-service/tests/test_humanitarian_pii_scrubbing.py new file mode 100644 index 00000000..73ea4688 --- /dev/null +++ b/app/ai-service/tests/test_humanitarian_pii_scrubbing.py @@ -0,0 +1,227 @@ +"""Tests for Issue #430: mandatory PII scrubbing on the humanitarian verification path. + +The verification pipeline must scrub ``aid_claim`` / ``supporting_evidence`` / +``context_factors`` before any provider call, fail closed when scrubbing is +disabled or unavailable, expose the scrubbed/raw distinction in the response +envelope, and record aggregate audit metadata (never text). +""" + +import pytest + +from config import settings +from services.humanitarian_verification import HumanitarianVerificationService +from persistence.pii_decisions import PIIDecisionStore + + +class TestHumanitarianPIIscrubbing: + def setup_method(self): + self.service = HumanitarianVerificationService() + + def _capture_provider(self, monkeypatch, response=None): + """Patch _call_provider to capture prompts; returns (calls, result).""" + calls = [] + + def fake_call_provider( + provider, model, system_prompt, user_prompt, timeout=None + ): + calls.append( + { + "provider": provider, + "system": system_prompt, + "user": user_prompt, + } + ) + if response is not None: + return response + return '{"verdict":"credible","confidence":0.9,"summary":"mocked"}' + + monkeypatch.setattr(settings, "openai_api_key", "test-key") + monkeypatch.setattr( + self.service, "_provider_attempt_order", lambda p: ["openai"] + ) + monkeypatch.setattr( + self.service, "_get_model_for_provider", lambda p: "test-model" + ) + monkeypatch.setattr(self.service, "_call_provider", fake_call_provider) + return calls + + def test_provider_payload_contains_no_raw_pii(self, monkeypatch): + """AC: evidence with a name/phone/email reaches the provider only as + masked tokens; the raw values never appear in the payload.""" + calls = self._capture_provider(monkeypatch) + + result = self.service.verify_claim( + aid_claim="Mary Johnson requested emergency food aid.", + supporting_evidence=[ + "Phone 08012345678 and email mary.johnson@example.com", + "Interview held in Maiduguri Camp.", + ], + context_factors={"contact": "mary.johnson@example.com"}, + provider_preference="openai", + ) + + assert result["pii_scrubbing"]["applied"] is True + assert result["pii_scrubbing"]["anonymized"] is True + assert result["pii_scrubbing"]["pii_summary"]["total"] > 0 + + assert len(calls) == 1 + prompt = calls[0]["user"] + assert "[RECIPIENT_NAME]" in prompt + assert "[PHONE_NUMBER]" in prompt + assert "[EMAIL_ADDRESS]" in prompt + assert "[LOCATION]" in prompt + for raw in ( + "Mary", + "Johnson", + "08012345678", + "mary.johnson@example.com", + "Maiduguri", + ): + assert raw not in prompt, f"raw PII leaked into provider payload: {raw!r}" + + def test_verify_claim_preserves_non_pii_text(self, monkeypatch): + """Scrubbing must not mangle evidence that contains no PII.""" + calls = self._capture_provider(monkeypatch) + + result = self.service.verify_claim( + aid_claim="Relief teams delivered hygiene kits to all registered households in the affected region.", + supporting_evidence=["Distribution list #B-17"], + context_factors={"security_status": "stable"}, + provider_preference="openai", + ) + + assert result["pii_scrubbing"]["applied"] is True + assert result["pii_scrubbing"]["anonymized"] is False + assert result["pii_scrubbing"]["pii_summary"]["total"] == 0 + + prompt = calls[0]["user"] + assert "Relief teams delivered hygiene kits" in prompt + assert "Distribution list #B-17" in prompt + assert "security_status: stable" in prompt + + def test_fail_closed_when_scrubber_unavailable(self, monkeypatch): + """AC: if scrubbing fails, the request is rejected and the provider is + never called (no unredacted fallback).""" + calls = self._capture_provider(monkeypatch) + + def boom(text): + raise RuntimeError("spaCy model unavailable") + + monkeypatch.setattr(self.service.scrubber, "scrub_text", boom) + + with pytest.raises(RuntimeError, match="PII scrubbing failed"): + self.service.verify_claim( + aid_claim="Mary Johnson requested emergency food aid.", + supporting_evidence=["Phone 08012345678"], + context_factors={}, + provider_preference="openai", + ) + + assert calls == [], "provider must not be called when scrubbing fails" + + def test_fail_closed_when_scrubbing_disabled(self, monkeypatch): + """AC: if scrubbing is disabled, the request is rejected rather than + sent unredacted.""" + calls = self._capture_provider(monkeypatch) + monkeypatch.setattr(settings, "pii_scrubbing_enabled", False) + + with pytest.raises(RuntimeError, match="PII scrubbing is disabled"): + self.service.verify_claim( + aid_claim="Mary Johnson requested emergency food aid.", + supporting_evidence=["Phone 08012345678"], + context_factors={}, + provider_preference="openai", + ) + + assert calls == [], "provider must not be called when scrubbing is disabled" + + def test_pii_decision_record_persisted_when_enabled(self, monkeypatch, tmp_path): + """Emit aggregate scrub metadata into pii_decisions (never text).""" + db_path = tmp_path / "pii.db" + monkeypatch.setattr(settings, "pii_decisions_enabled", True) + monkeypatch.setattr(settings, "pii_decisions_db_path", str(db_path)) + monkeypatch.setattr(settings, "test_provider_mode", True) + monkeypatch.setattr(settings, "openai_api_key", None) + monkeypatch.setattr(settings, "groq_api_key", None) + + store = PIIDecisionStore(str(db_path)) + store.initialize() + + result = self.service.verify_claim( + aid_claim="Mary Johnson requested emergency food aid.", + supporting_evidence=["Phone 08012345678"], + context_factors={}, + provider_preference="auto", + ) + + assert result["provider"] == "test" + assert result["pii_scrubbing"]["anonymized"] is True + + rows = store.get_recent_decisions() + assert len(rows) == 1 + record = rows[0] + assert record["pii_summary"]["total"] > 0 + assert record["pii_summary"]["names"] >= 1 + assert record["text_fingerprint"] + assert record["model_version"] == "test-provider/fixture" + # Aggregate-only guardrail: raw or anonymized text must never be stored. + assert "anonymized_text" not in record + assert "Mary" not in str(record) + + def test_pii_decision_not_recorded_when_disabled(self, monkeypatch, tmp_path): + """Audit store stays untouched when pii_decisions_enabled is off.""" + db_path = tmp_path / "pii.db" + monkeypatch.setattr(settings, "pii_decisions_enabled", False) + monkeypatch.setattr(settings, "pii_decisions_db_path", str(db_path)) + monkeypatch.setattr(settings, "test_provider_mode", True) + monkeypatch.setattr(settings, "openai_api_key", None) + monkeypatch.setattr(settings, "groq_api_key", None) + + store = PIIDecisionStore(str(db_path)) + store.initialize() + + self.service.verify_claim( + aid_claim="Mary Johnson requested emergency food aid.", + supporting_evidence=["Phone 08012345678"], + context_factors={}, + provider_preference="auto", + ) + + assert store.count() == 0 + + def test_route_rejects_when_scrubbing_disabled(self, monkeypatch): + """End-to-end: the /v1/ai/humanitarian/verify route surfaces the + fail-closed rejection instead of sending unredacted evidence.""" + import main as _main + + calls = [] + monkeypatch.setattr(settings, "pii_scrubbing_enabled", False) + monkeypatch.setattr( + _main.humanitarian_verification_service, + "_provider_attempt_order", + lambda p: ["openai"], + ) + monkeypatch.setattr( + _main.humanitarian_verification_service, + "_call_provider", + lambda *a, **k: calls.append(a) or '{"verdict":"credible"}', + ) + + from fastapi.testclient import TestClient + + client = TestClient(_main.app) + response = client.post( + "/v1/ai/humanitarian/verify", + json={ + "aid_claim": "Mary Johnson requested emergency food aid.", + "supporting_evidence": ["Phone 08012345678"], + "context_factors": {}, + "provider_preference": "openai", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["success"] is False + assert "PII scrubbing is disabled" in data["error"] + assert calls == [], "provider must not be called when scrubbing is disabled" diff --git a/app/ai-service/tests/test_humanitarian_verification.py b/app/ai-service/tests/test_humanitarian_verification.py index fb366540..41d1c08a 100644 --- a/app/ai-service/tests/test_humanitarian_verification.py +++ b/app/ai-service/tests/test_humanitarian_verification.py @@ -10,11 +10,16 @@ class TestHumanitarianVerificationService: def setup_method(self): self.service = HumanitarianVerificationService() - @patch('metrics.PIPELINE_STEP_LATENCY.labels') - def test_verify_claim_uses_fallback_prompt_after_primary_failure(self, mock_labels, monkeypatch): - mock_observe = MagicMock() - mock_labels.return_value.observe = mock_observe - + def test_verify_claim_uses_fallback_prompt_after_primary_failure(self, monkeypatch): + observed_steps = [] + + def fake_labels(step_name): + mock = MagicMock() + mock.observe.side_effect = lambda value: observed_steps.append(step_name) + return mock + + monkeypatch.setattr(metrics.PIPELINE_STEP_LATENCY, "labels", fake_labels) + calls = [] def fake_attempt_order(provider_preference): @@ -44,9 +49,11 @@ def fake_call_provider(provider, model, system_prompt, user_prompt, timeout=None assert result["provider"] == "openai" assert result["verification"]["verdict"] == "inconclusive" assert len(calls) == 2 - - mock_labels.assert_called_with(step_name='verify') - mock_observe.assert_called_once() + + # The verify step is observed exactly once; the PII scrub + # preprocessing stage emits its own 'scrub' observation via the + # same PIPELINE_STEP_LATENCY metric (Issue #430). + assert observed_steps.count("verify") == 1 def test_verify_claim_fails_when_no_provider_configured(self, monkeypatch): monkeypatch.setattr(self.service, "_provider_attempt_order", lambda provider_preference: [])