""" 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), )