Skip to content
Merged
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
28 changes: 21 additions & 7 deletions app/ai-service/api/v1/anonymize.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,43 @@
"""

import logging
from typing import Any, Dict

from fastapi import APIRouter, HTTPException

from schemas.anonymization import AnonymizeRequest, AnonymizeResponse
from schemas.anonymization import AnonymizeRequest, AnonymizeResult
from schemas.common import ResultEnvelope

logger = logging.getLogger(__name__)

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


@router.post("/ai/anonymize", response_model=AnonymizeResponse)
async def anonymize_text(request: AnonymizeRequest):
@router.post("/ai/anonymize", response_model=ResultEnvelope[AnonymizeResult])
async def anonymize_text(request: AnonymizeRequest) -> ResultEnvelope[AnonymizeResult]:
"""Anonymize names, locations, and dates before text is sent to external LLMs."""
import main as _main
from main import correlation_id_var

logger.info("Processing privacy-preserving anonymization request")

try:
result = _main.pii_scrubber_service.anonymize(request.text)
return AnonymizeResponse(
success=True,
raw: Dict[str, Any] = _main.pii_scrubber_service.anonymize(request.text)
result = AnonymizeResult(**raw)

# PII scrubbing is deterministic — no confidence score to report.
reasons = (
[f"Detected and masked {result.pii_summary.get('total', 0)} PII item(s)."]
if result.pii_summary.get("total", 0) > 0
else ["No PII detected in input text."]
)

return ResultEnvelope[AnonymizeResult](
result=result,
confidence=None,
reasons=reasons,
anchor_metadata=request.anchor_metadata,
**result
trace_id=correlation_id_var.get() or None,
)
except Exception as e:
logger.error(f"Anonymization failed: {str(e)}", exc_info=True)
Expand Down
36 changes: 29 additions & 7 deletions app/ai-service/api/v1/fraud.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,32 +3,54 @@
"""

import logging
from typing import List

from fastapi import APIRouter, HTTPException

from schemas.fraud import FraudDetectionRequest, FraudDetectionResponse
from schemas.common import ResultEnvelope
from schemas.fraud import ClaimFraudResult, FraudDetectionRequest
from services.fraud_detection import detect_fraud

logger = logging.getLogger(__name__)

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


@router.post("/fraud/detect", response_model=FraudDetectionResponse)
async def detect_fraud_endpoint(request: FraudDetectionRequest) -> FraudDetectionResponse:
@router.post("/fraud/detect", response_model=ResultEnvelope[List[ClaimFraudResult]])
async def detect_fraud_endpoint(request: FraudDetectionRequest) -> ResultEnvelope[List[ClaimFraudResult]]:
"""
Analyse a batch of claims for suspicious patterns.

Returns a ``fraud_risk_score`` (0–1) for each claim. Claims that are
statistical outliers relative to the batch are flagged with
``is_flagged=true``.
"""
from main import correlation_id_var

try:
results = detect_fraud(request.claims)
return FraudDetectionResponse(
results=results,
flagged_count=sum(r.is_flagged for r in results),
anchor_metadata=request.anchor_metadata

flagged = [r for r in results if r.is_flagged]
reasons = [
f"claim_id={r.claim_id}: {r.reason}"
for r in flagged
if r.reason
] or None

# Aggregate confidence: 1 - mean(fraud_risk_score of flagged claims), or
# 1 - mean(all scores) as overall cleanliness confidence.
if results:
avg_risk = sum(r.fraud_risk_score for r in results) / len(results)
confidence = round(1.0 - avg_risk, 4)
else:
confidence = None

return ResultEnvelope[List[ClaimFraudResult]](
result=results,
confidence=confidence,
reasons=reasons,
anchor_metadata=request.anchor_metadata,
trace_id=correlation_id_var.get() or None,
)
except Exception as exc:
logger.error("Fraud detection failed: %s", exc)
Expand Down
50 changes: 35 additions & 15 deletions app/ai-service/api/v1/humanitarian.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,33 @@
"""

import logging
from typing import Any, Dict, List, Optional

from fastapi import APIRouter

from schemas.common import ResultEnvelope
from schemas.humanitarian import (
HumanitarianVerificationRequest,
HumanitarianVerificationResponse,
)

logger = logging.getLogger(__name__)

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


@router.post("/ai/humanitarian/verify", response_model=HumanitarianVerificationResponse)
async def verify_humanitarian_claim(request: HumanitarianVerificationRequest):
@router.post("/ai/humanitarian/verify", response_model=ResultEnvelope[Dict[str, Any]])
async def verify_humanitarian_claim(
request: HumanitarianVerificationRequest,
) -> ResultEnvelope[Dict[str, Any]]:
"""Verify an aid claim against standardised humanitarian criteria."""
# Delegate to the singleton owned by main.py so that monkeypatching in
# tests (and any future dependency-injection wiring) works transparently.
import main as _main
from main import correlation_id_var

logger.info("Processing humanitarian verification request")

try:
try:
result = _main.humanitarian_verification_service.verify_claim(
raw = _main.humanitarian_verification_service.verify_claim(
aid_claim=request.aid_claim,
supporting_evidence=request.supporting_evidence,
context_factors=request.context_factors,
Expand All @@ -36,23 +38,41 @@ async def verify_humanitarian_claim(request: HumanitarianVerificationRequest):
)
except TypeError as exc:
if "timeout" in str(exc):
result = _main.humanitarian_verification_service.verify_claim(
raw = _main.humanitarian_verification_service.verify_claim(
aid_claim=request.aid_claim,
supporting_evidence=request.supporting_evidence,
context_factors=request.context_factors,
provider_preference=request.provider_preference,
)
else:
raise exc
return HumanitarianVerificationResponse(
success=True,

verification: Dict[str, Any] = raw.get("verification") or {}

# 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)):
confidence = round(float(max(0.0, min(1.0, raw_conf))), 4)

reasons: Optional[List[str]] = None
for key in ("reasoning", "reason", "summary", "explanation"):
raw_reason = verification.get(key)
if isinstance(raw_reason, str) and raw_reason:
reasons = [raw_reason]
break
if isinstance(raw_reason, list) and raw_reason:
reasons = [str(r) for r in raw_reason]
break

return ResultEnvelope[Dict[str, Any]](
result=raw,
confidence=confidence,
reasons=reasons,
anchor_metadata=request.anchor_metadata,
**result
trace_id=correlation_id_var.get() or None,
)
except Exception as e:
logger.error("Humanitarian verification failed: %s", str(e), exc_info=True)
return HumanitarianVerificationResponse(
success=False,
error=str(e),
anchor_metadata=request.anchor_metadata
)
# Re-raise so the global exception handler formats the error envelope
raise
33 changes: 24 additions & 9 deletions app/ai-service/api/v1/ocr.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
from slowapi.util import get_remote_address

import tasks
from schemas.ocr import OCRResponse
from schemas.ocr import OCRData
from schemas.common import ResultEnvelope
from services.ocr_job import run_ocr_from_bytes
from config import settings

Expand Down Expand Up @@ -46,7 +47,7 @@ async def process_ocr(
request: Request,
image: Annotated[UploadFile, File(description="Image file to process")],
anchor_metadata: Annotated[Optional[str], Form(description="JSON encoded AnchorMetadata")] = None,
) -> OCRResponse:
) -> ResultEnvelope[OCRData]:
"""Extract text fields from an uploaded document image."""
start_time = time.time()

Expand Down Expand Up @@ -75,22 +76,36 @@ async def process_ocr(
)

_validate_image_bytes(contents)
result = run_ocr_from_bytes(contents, anchor_metadata)
raw = run_ocr_from_bytes(contents, anchor_metadata)

from main import correlation_id_var
ocr_data = OCRData(**raw["data"]) if isinstance(raw["data"], dict) else raw["data"]
fields = ocr_data.fields
avg_confidence: Optional[float] = (
round(sum(f.confidence for f in fields.values()) / len(fields), 4)
if fields
else None
)

return OCRResponse(**result)
return ResultEnvelope[OCRData](
result=ocr_data,
confidence=avg_confidence,
reasons=None,
anchor_metadata=raw.get("anchor_metadata"),
trace_id=correlation_id_var.get() or None,
)

except HTTPException:
raise
except Exception as e:
processing_time_ms = int((time.time() - start_time) * 1000)
return OCRResponse(
success=False,
error={
# Surface as a structured HTTP error rather than returning a partial envelope
raise HTTPException(
status_code=500,
detail={
"code": "processing_error",
"message": str(e),
},
processing_time_ms=processing_time_ms,
anchor_metadata=None, # Cannot easily re-parse here without duplicating, so omit or ignore
)


Expand Down
78 changes: 57 additions & 21 deletions app/ai-service/api/v1/proof_of_life.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from schemas.common import AnchorMetadata
from schemas.common import AnchorMetadata, ResultEnvelope

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -35,6 +35,31 @@ class ProofOfLifeRequest(BaseModel):
}


class ProofOfLifeResult(BaseModel):
"""
Core proof-of-life payload nested inside the ResultEnvelope.

``confidence`` and ``reason`` are promoted to the envelope level;
this model carries the remaining domain-specific fields.
"""

is_real_person: bool = Field(examples=[True])
threshold: float = Field(examples=[0.8])
checks: Dict[str, Any] = Field(examples=[{"face_detected": True, "liveness_check": "passed"}])

model_config = {
"json_schema_extra": {
"examples": [
{
"is_real_person": True,
"threshold": 0.8,
"checks": {"face_detected": True, "liveness_check": "passed"},
}
]
}
}


class ProofOfLifeResponse(BaseModel):
"""Response model for proof-of-life analysis."""

Expand Down Expand Up @@ -68,39 +93,50 @@ class ProofOfLifeResponse(BaseModel):
}


@router.post("/ai/proof-of-life", response_model=ProofOfLifeResponse)
async def analyze_proof_of_life(request: ProofOfLifeRequest):
@router.post("/ai/proof-of-life", response_model=ResultEnvelope[ProofOfLifeResult])
async def analyze_proof_of_life(
request: ProofOfLifeRequest,
) -> ResultEnvelope[ProofOfLifeResult]:
"""
Analyse a selfie image (with optional burst frames) for proof-of-life.

Returns ``is_real_person`` and a confidence score. When burst frames
are provided, the service additionally checks for liveness signals
such as blink detection and head movement.
Returns ``is_real_person`` and a confidence score inside a standardized
result envelope. When burst frames are provided, the service additionally
checks for liveness signals such as blink detection and head movement.
"""

import main as _main
from main import correlation_id_var

logger.info("Processing proof-of-life verification request")

try:
result = _main.proof_of_life_analyzer.analyze(
raw = _main.proof_of_life_analyzer.analyze(
selfie_image_base64=request.selfie_image_base64,
burst_images_base64=request.burst_images_base64,
confidence_threshold=request.confidence_threshold,
)
# Ensure we return a ProofOfLifeResponse object with anchor_metadata
if isinstance(result, dict):
return ProofOfLifeResponse(
**result,
anchor_metadata=request.anchor_metadata
)
else:
# If result is already a BaseModel instance
result_dict = result.model_dump() if hasattr(result, "model_dump") else result.dict()
return ProofOfLifeResponse(
**result_dict,
anchor_metadata=request.anchor_metadata
)
raw_dict: Dict[str, Any] = (
raw.model_dump() if hasattr(raw, "model_dump") else
raw.dict() if hasattr(raw, "dict") else
dict(raw)
)

confidence: Optional[float] = raw_dict.get("confidence")
reason: Optional[str] = raw_dict.get("reason")

result = ProofOfLifeResult(
is_real_person=raw_dict["is_real_person"],
threshold=raw_dict["threshold"],
checks=raw_dict.get("checks", {}),
)

return ResultEnvelope[ProofOfLifeResult](
result=result,
confidence=confidence,
reasons=[reason] if reason else None,
anchor_metadata=request.anchor_metadata,
trace_id=correlation_id_var.get() or None,
)
except ValueError as e:
raise HTTPException(status_code=422, detail=str(e))
except Exception as e:
Expand Down
Loading
Loading