Implement full evidence integrity pipeline to prevent compliance theater: - Confidence levels (E0-E4), truth status tracking, assertion engine - Four-Eyes approval workflow, audit trail, reject endpoint - Evidence distribution dashboard, LLM audit routes - Traceability matrix (backend endpoint + Compliance Hub UI tab) - Anti-fake badges, control status machine, normative patterns - 2 migrations, 4 test suites, MkDocs documentation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
228 lines
7.2 KiB
Python
228 lines
7.2 KiB
Python
"""
|
|
API routes for Assertion Engine (Anti-Fake-Evidence Phase 2).
|
|
|
|
Endpoints:
|
|
- /assertions: CRUD for assertions
|
|
- /assertions/extract: Automatic extraction from entity text
|
|
- /assertions/summary: Stats (total assertions, facts, unverified)
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
|
|
from ..db.models import AssertionDB
|
|
from ..services.assertion_engine import extract_assertions
|
|
from .schemas import (
|
|
AssertionCreate,
|
|
AssertionUpdate,
|
|
AssertionResponse,
|
|
AssertionListResponse,
|
|
AssertionSummaryResponse,
|
|
AssertionExtractRequest,
|
|
)
|
|
from .audit_trail_utils import log_audit_trail, generate_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(tags=["compliance-assertions"])
|
|
|
|
|
|
def _build_assertion_response(a: AssertionDB) -> AssertionResponse:
|
|
return AssertionResponse(
|
|
id=a.id,
|
|
tenant_id=a.tenant_id,
|
|
entity_type=a.entity_type,
|
|
entity_id=a.entity_id,
|
|
sentence_text=a.sentence_text,
|
|
sentence_index=a.sentence_index,
|
|
assertion_type=a.assertion_type,
|
|
evidence_ids=a.evidence_ids or [],
|
|
confidence=a.confidence or 0.0,
|
|
normative_tier=a.normative_tier,
|
|
verified_by=a.verified_by,
|
|
verified_at=a.verified_at,
|
|
created_at=a.created_at,
|
|
updated_at=a.updated_at,
|
|
)
|
|
|
|
|
|
@router.post("/assertions", response_model=AssertionResponse)
|
|
async def create_assertion(
|
|
data: AssertionCreate,
|
|
tenant_id: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Create a single assertion manually."""
|
|
a = AssertionDB(
|
|
id=generate_id(),
|
|
tenant_id=tenant_id,
|
|
entity_type=data.entity_type,
|
|
entity_id=data.entity_id,
|
|
sentence_text=data.sentence_text,
|
|
assertion_type=data.assertion_type or "assertion",
|
|
evidence_ids=data.evidence_ids or [],
|
|
normative_tier=data.normative_tier,
|
|
)
|
|
db.add(a)
|
|
db.commit()
|
|
db.refresh(a)
|
|
return _build_assertion_response(a)
|
|
|
|
|
|
@router.get("/assertions", response_model=AssertionListResponse)
|
|
async def list_assertions(
|
|
entity_type: Optional[str] = Query(None),
|
|
entity_id: Optional[str] = Query(None),
|
|
assertion_type: Optional[str] = Query(None),
|
|
tenant_id: Optional[str] = Query(None),
|
|
limit: int = Query(100, ge=1, le=500),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List assertions with optional filters."""
|
|
query = db.query(AssertionDB)
|
|
if entity_type:
|
|
query = query.filter(AssertionDB.entity_type == entity_type)
|
|
if entity_id:
|
|
query = query.filter(AssertionDB.entity_id == entity_id)
|
|
if assertion_type:
|
|
query = query.filter(AssertionDB.assertion_type == assertion_type)
|
|
if tenant_id:
|
|
query = query.filter(AssertionDB.tenant_id == tenant_id)
|
|
|
|
total = query.count()
|
|
records = query.order_by(AssertionDB.sentence_index.asc()).limit(limit).all()
|
|
|
|
return AssertionListResponse(
|
|
assertions=[_build_assertion_response(a) for a in records],
|
|
total=total,
|
|
)
|
|
|
|
|
|
@router.get("/assertions/summary", response_model=AssertionSummaryResponse)
|
|
async def assertion_summary(
|
|
tenant_id: Optional[str] = Query(None),
|
|
entity_type: Optional[str] = Query(None),
|
|
entity_id: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Summary stats: total assertions, facts, rationale, unverified."""
|
|
query = db.query(AssertionDB)
|
|
if tenant_id:
|
|
query = query.filter(AssertionDB.tenant_id == tenant_id)
|
|
if entity_type:
|
|
query = query.filter(AssertionDB.entity_type == entity_type)
|
|
if entity_id:
|
|
query = query.filter(AssertionDB.entity_id == entity_id)
|
|
|
|
all_records = query.all()
|
|
|
|
total = len(all_records)
|
|
facts = sum(1 for a in all_records if a.assertion_type == "fact")
|
|
rationale = sum(1 for a in all_records if a.assertion_type == "rationale")
|
|
unverified = sum(1 for a in all_records if a.assertion_type == "assertion" and not a.verified_by)
|
|
|
|
return AssertionSummaryResponse(
|
|
total_assertions=total,
|
|
total_facts=facts,
|
|
total_rationale=rationale,
|
|
unverified_count=unverified,
|
|
)
|
|
|
|
|
|
@router.get("/assertions/{assertion_id}", response_model=AssertionResponse)
|
|
async def get_assertion(
|
|
assertion_id: str,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get a single assertion by ID."""
|
|
a = db.query(AssertionDB).filter(AssertionDB.id == assertion_id).first()
|
|
if not a:
|
|
raise HTTPException(status_code=404, detail=f"Assertion {assertion_id} not found")
|
|
return _build_assertion_response(a)
|
|
|
|
|
|
@router.put("/assertions/{assertion_id}", response_model=AssertionResponse)
|
|
async def update_assertion(
|
|
assertion_id: str,
|
|
data: AssertionUpdate,
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update an assertion (e.g. link evidence, change type)."""
|
|
a = db.query(AssertionDB).filter(AssertionDB.id == assertion_id).first()
|
|
if not a:
|
|
raise HTTPException(status_code=404, detail=f"Assertion {assertion_id} not found")
|
|
|
|
update_fields = data.model_dump(exclude_unset=True)
|
|
for key, value in update_fields.items():
|
|
setattr(a, key, value)
|
|
a.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(a)
|
|
return _build_assertion_response(a)
|
|
|
|
|
|
@router.post("/assertions/{assertion_id}/verify", response_model=AssertionResponse)
|
|
async def verify_assertion(
|
|
assertion_id: str,
|
|
verified_by: str = Query(...),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Mark an assertion as verified fact."""
|
|
a = db.query(AssertionDB).filter(AssertionDB.id == assertion_id).first()
|
|
if not a:
|
|
raise HTTPException(status_code=404, detail=f"Assertion {assertion_id} not found")
|
|
|
|
a.assertion_type = "fact"
|
|
a.verified_by = verified_by
|
|
a.verified_at = datetime.utcnow()
|
|
a.updated_at = datetime.utcnow()
|
|
db.commit()
|
|
db.refresh(a)
|
|
return _build_assertion_response(a)
|
|
|
|
|
|
@router.post("/assertions/extract", response_model=AssertionListResponse)
|
|
async def extract_assertions_endpoint(
|
|
data: AssertionExtractRequest,
|
|
tenant_id: Optional[str] = Query(None),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Extract assertions from free text and persist them."""
|
|
extracted = extract_assertions(
|
|
text=data.text,
|
|
entity_type=data.entity_type,
|
|
entity_id=data.entity_id,
|
|
tenant_id=tenant_id,
|
|
)
|
|
|
|
created = []
|
|
for item in extracted:
|
|
a = AssertionDB(
|
|
id=generate_id(),
|
|
tenant_id=item["tenant_id"],
|
|
entity_type=item["entity_type"],
|
|
entity_id=item["entity_id"],
|
|
sentence_text=item["sentence_text"],
|
|
sentence_index=item["sentence_index"],
|
|
assertion_type=item["assertion_type"],
|
|
evidence_ids=item["evidence_ids"],
|
|
normative_tier=item.get("normative_tier"),
|
|
confidence=item.get("confidence", 0.0),
|
|
)
|
|
db.add(a)
|
|
created.append(a)
|
|
|
|
db.commit()
|
|
for a in created:
|
|
db.refresh(a)
|
|
|
|
return AssertionListResponse(
|
|
assertions=[_build_assertion_response(a) for a in created],
|
|
total=len(created),
|
|
)
|