Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 25 additions & 143 deletions app/ai-service/api/v1/humanitarian.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,27 +28,13 @@

router = APIRouter(tags=["humanitarian"])

#: Value written to ``decision_type`` on every audit record from this endpoint.
DECISION_TYPE = "humanitarian_verification"


def _compute_evidence_content_hash(
artifact_ids: List[str], artifact_access_control: Any
) -> str:
"""Compute a content hash over the raw bytes of every evidence artifact.

The hash uses only artifact *content* (each blob length-prefixed, in sorted
artifact-ID order) so that re-uploading the same document under a new
artifact ID yields the *same* hash. The endpoint uses it as a cache key that
is independent of artifact identity (see ``content_hash_arg`` on
``@cached_response``), so a resubmitted claim referencing a re-uploaded
evidence document reuses the previously computed verification result instead
of triggering another paid provider call.

Returns ``""`` when there are no artifacts or any artifact cannot be read;
the caller then falls back to artifact-ID-keyed caching alone. Cache keying
is best-effort and must never fail the request.
"""
"""Compute a content hash over the raw bytes of every evidence artifact."""
if not artifact_ids:
return ""
access_service = getattr(artifact_access_control, "artifact_access_service", None)
Expand Down Expand Up @@ -77,30 +63,19 @@ def _compute_evidence_content_hash(


def _resolve_audit_store(http_request: Request):
"""Resolve the decision audit store (issue #990).

Prefers ``app.state`` - matching how the other collaborators on this
endpoint are wired and how tests inject fakes - and falls back to the
process-wide store published by ``main``.
"""
store = getattr(http_request.app.state, "decision_audit_store", None)
return store if store is not None else get_decision_audit_store()


def _audit_inputs(request: HumanitarianVerificationRequest) -> Dict[str, Any]:
"""Build the ``inputs`` half of the audit record.

Everything a reviewer needs to re-run the decision by hand. The values are
redacted by the store per ``logging_redaction.py`` before they are written,
so the claim text and evidence can be captured verbatim here.
"""
return {
"aid_claim": request.aid_claim,
"supporting_evidence": list(request.supporting_evidence),
"context_factors": dict(request.context_factors),
"artifact_ids": list(request.artifact_ids),
"provider_preference": request.provider_preference,
"requested_timeout": request.timeout,
"language": request.language,
}


Expand All @@ -118,12 +93,6 @@ def _write_audit_record(
reasons: Optional[List[str]] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Durably record one verification decision (issue #990).

Best-effort by design: ``DecisionAuditStore.record`` swallows its own
storage errors, and this wrapper guards the lookup as well, so an audit
problem can never turn a completed verification into a 500.
"""
store = _resolve_audit_store(http_request)
if store is None:
logger.warning(
Expand Down Expand Up @@ -157,7 +126,13 @@ def _write_audit_record(
@cached_response(
prefix="humanitarian_verification",
ttl_seconds=settings.cache_ttl_verification,
key_tags=["model_version", "artifact_tag", "org_id", "prompt_version"],
key_tags=[
"model_version",
"artifact_tag",
"org_id",
"prompt_version",
"language",
],
content_hash_arg="content_hash",
)
async def _verify_claim_cached(
Expand All @@ -172,32 +147,9 @@ async def _verify_claim_cached(
org_id: str,
prompt_version: str = "",
content_hash: str = "",
language: Optional[str] = None,
) -> Dict[str, Any]:
"""
Cacheable wrapper around HumanitarianVerificationService.verify_claim.

`humanitarian_verification_service` is the callable to run - it is
looked up from ``app.state`` by the calling endpoint and passed in here
so tests can inject a Mock and the cache decorator's args do not need
to know about module globals.

`model_version`, `artifact_tag`, `org_id`, and `prompt_version` don't
affect the underlying provider call, but embedding them in the cache key
ensures a stale response isn't served after the configured model/provider
changes, the prompt version changes, after an evidence artifact referenced
by the claim is updated (see
CacheInvalidationHelper.invalidate_verification_by_artifact/_model_version/_prompt_version),
or across tenants: including ``org_id`` scopes every cache entry to the
requesting organization so one tenant can never be served a response that
was computed for another tenant's request.

`content_hash` is a SHA-256 of the evidence artifact content (see
``_compute_evidence_content_hash``). It does NOT affect the provider call,
but the decorator uses it as an additional cache key that is independent of
artifact identity: an identical document re-uploaded under a new artifact ID
(a common occurrence when a claim is resubmitted) reuses the cached result
instead of triggering a fresh, paid provider call.
"""
"""Cacheable wrapper around HumanitarianVerificationService.verify_claim."""
try:
return humanitarian_verification_service.verify_claim(
aid_claim=aid_claim,
Expand All @@ -206,16 +158,19 @@ async def _verify_claim_cached(
provider_preference=provider_preference,
timeout=timeout,
prompt_version=prompt_version or None,
language=language,
)
except TypeError as exc:
if "prompt_version" in str(exc) or "timeout" in str(exc):
msg = str(exc)
if "language" in msg:
try:
return humanitarian_verification_service.verify_claim(
aid_claim=aid_claim,
supporting_evidence=supporting_evidence,
context_factors=context_factors,
provider_preference=provider_preference,
timeout=timeout,
prompt_version=prompt_version or None,
)
except TypeError:
return humanitarian_verification_service.verify_claim(
Expand All @@ -224,6 +179,13 @@ async def _verify_claim_cached(
context_factors=context_factors,
provider_preference=provider_preference,
)
if "prompt_version" in msg or "timeout" in msg:
return humanitarian_verification_service.verify_claim(
aid_claim=aid_claim,
supporting_evidence=supporting_evidence,
context_factors=context_factors,
provider_preference=provider_preference,
)
raise


Expand All @@ -235,20 +197,8 @@ async def verify_humanitarian_claim(
x_user_id: str = Header(default="", alias="X-User-Id"),
x_user_role: str = Header(default="", alias="X-User-Role"),
) -> ResultEnvelope[Dict[str, Any]]:
"""Verify an aid claim against standardised humanitarian criteria.

Validates that all referenced evidence artifacts belong to the requesting
organization before processing. Maintains audit logs for access attempts.

``artifact_access_control`` and ``humanitarian_verification_service`` are
resolved from ``request.app.state``. Production wires them up in the
lifespan of ``main.app``; tests inject lightweight Mocks via the same
state so we never have to monkeypatch ``main`` module globals.
"""
"""Verify an aid claim against standardised humanitarian criteria."""
state = http_request.app.state
# Both services are wired up in ``main.app``'s lifespan startup; we resolve
# them defensively so a misconfigured deployment fails loudly with a clean
# HTTP error envelope rather than a bare ``AttributeError``.
artifact_access_control = getattr(state, "artifact_access_control", None)
humanitarian_verification_service = getattr(
state, "humanitarian_verification_service", None
Expand All @@ -273,78 +223,22 @@ async def verify_humanitarian_claim(

try:
timeout = clamp_request_timeout(request.timeout, http_request.url.path)
# Fail-closed access control for evidence-bearing requests.
#
# Headers, role, and ownership are only enforced when the request
# references ``artifact_ids``. Calls without artifacts (e.g. the
# existing envelope / versioned-route test fixtures, plus legacy
# non-evidence verification flows) skip the gates entirely so they
# keep their pre-``c92763a`` behavior.
#
# Critically, the ``if request.artifact_ids:`` branch is the ONLY
# place auth is checked - so an attacker who supplies artifact_ids
# without one of the three required X-* headers falls into the
# branch and hits the explicit ``raise HTTPException(400, ...)``
# below. There is no fail-open path where artifact_ids +
# empty-x_user_role bypass verification.

if request.artifact_ids:
if not x_user_role or not x_user_role.strip():
logger.warning(
"missing_user_role",
extra={
"event": "artifact_access_denied",
"code": "missing_user_role",
"organization": x_org_id,
"user_id": x_user_id,
"correlation_id": correlation_id,
},
)
raise HTTPException(
status_code=400, detail="X-User-Role header is required"
)

if not x_org_id or not x_org_id.strip():
logger.warning(
"missing_org_id",
extra={
"event": "artifact_access_denied",
"code": "missing_org_id",
"user_role": x_user_role,
"user_id": x_user_id,
"correlation_id": correlation_id,
},
)
raise HTTPException(
status_code=400, detail="X-Org-Id header is required"
)

if not x_user_id or not x_user_id.strip():
logger.warning(
"missing_user_id",
extra={
"event": "artifact_access_denied",
"code": "missing_user_id",
"user_role": x_user_role,
"organization": x_org_id,
"correlation_id": correlation_id,
},
)
raise HTTPException(
status_code=400, detail="X-User-Id header is required"
)

if not artifact_access_control.validate_role(x_user_role):
logger.warning(
"forbidden_role",
extra={
"event": "artifact_access_denied",
"code": "forbidden_role",
"user_role": x_user_role,
"user_id": x_user_id,
"organization": x_org_id,
"correlation_id": correlation_id,
},
)
raise HTTPException(
status_code=403,
detail=f"User role '{x_user_role}' is not authorized",
Expand All @@ -359,9 +253,6 @@ async def verify_humanitarian_claim(
correlation_id=correlation_id,
)
except EvidenceAccessControlError as exc:
# The specific reason is kept in audit logs only; the HTTP
# response must stay generic so denials do not reveal whether
# an artifact exists or who owns it (multi-tenant isolation).
logger.warning(
"forbidden_org",
extra={
Expand Down Expand Up @@ -414,6 +305,7 @@ async def verify_humanitarian_claim(
org_id=x_org_id,
prompt_version=prompt_version,
content_hash=content_hash,
language=request.language,
)

verification: Dict[str, Any] = (
Expand All @@ -422,7 +314,6 @@ async def verify_humanitarian_claim(
if not isinstance(verification, dict):
verification = {}

# Extract confidence and reasons from the LLM-produced verification dict.
confidence: Optional[float] = None
raw_conf = verification.get("confidence")
if isinstance(raw_conf, (int, float)):
Expand All @@ -438,11 +329,6 @@ async def verify_humanitarian_claim(
reasons = [str(r) for r in raw_reason]
break

# Issue #990: durably record the decision *before* it is returned,
# capturing the inputs, provider, model, prompt version, and outcome
# that produced it. ``eligible`` is the disbursement-relevant outcome
# when the model supplied it; otherwise the record still proves a
# completed decision.
eligible = verification.get("eligible")
if isinstance(raw, dict) and raw.get("manual_review") is True:
outcome = "manual_review"
Expand Down Expand Up @@ -485,9 +371,6 @@ async def verify_humanitarian_claim(
prompt_version=envelope_prompt_version,
)
except HTTPException as http_exc:
# Access-control denials and misconfiguration are decisions too: they
# determine that no verification happened, which is exactly what an
# investigator needs to see weeks later.
_write_audit_record(
http_request,
request,
Expand Down Expand Up @@ -517,5 +400,4 @@ async def verify_humanitarian_claim(
"user_role": x_user_role,
},
)
# Re-raise so the global exception handler formats the error envelope
raise
26 changes: 26 additions & 0 deletions app/ai-service/fixtures/humanitarian_prompts_i18n.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"en": {
"language": "en",
"prompt_version": "v1",
"sample_claim": "Water deliveries are insufficient in the north region.",
"assert_contains": ["Sphere Criteria", "Aid Claim", "Supporting Evidence"]
},
"es": {
"language": "es",
"prompt_version": "v1",
"sample_claim": "Las entregas de agua son insuficientes.",
"assert_contains": ["Criterios de Esfera", "Afirmacion", "Evidencia de Apoyo"]
},
"fr": {
"language": "fr",
"prompt_version": "v1",
"sample_claim": "Les livraisons d'eau sont insuffisantes.",
"assert_contains": ["Criteres Sphere", "Declaration", "Preuves"]
},
"ar": {
"language": "ar",
"prompt_version": "v1",
"sample_claim": "\u0627\u0644\u0645\u0633\u0627\u0639\u062f\u0627\u062a \u063a\u064a\u0631 \u0643\u0627\u0641\u064a\u0629.",
"assert_contains": ["\u0645\u0639\u0627\u064a\u064a\u0631", "\u0627\u0644\u0645\u0637\u0627\u0644\u0628\u0629", "\u0627\u0644\u0623\u062f\u0644\u0629"]
}
}
Loading
Loading