"""Claims, Evidence, and Attestations endpoints.""" import json import uuid from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel from spore_node.db.connection import get_pool from spore_node.rid_types import SporeClaim, SporeEvidence, SporeAttestation router = APIRouter(prefix="/claims", tags=["claims"]) # --- Models --- VALID_STATUSES = ("proposed", "supported", "challenged", "superseded") VALID_TRANSITIONS = { "proposed": ("supported", "challenged", "superseded"), "supported": ("challenged", "superseded"), "challenged": ("supported", "superseded"), "superseded": (), } class ClaimCreate(BaseModel): content: str proposer_rid: str confidence: float = 0.5 anchor_type: str = "assertion" metadata: dict = {} class ClaimResponse(BaseModel): id: str rid: str proposer_rid: str content: str status: str confidence: float anchor_type: str metadata: dict class EvidenceCreate(BaseModel): relation: str # supports | challenges body: str provenance: dict = {} class EvidenceResponse(BaseModel): id: str rid: str claim_id: str relation: str body: str provenance: dict class AttestationCreate(BaseModel): attester_rid: str verdict: str # endorse | dispute | abstain strength: float = 1.0 reasoning: str = "" class AttestationResponse(BaseModel): id: str rid: str claim_id: str attester_rid: str verdict: str strength: float reasoning: str class ClaimStrengthResponse(BaseModel): claim_id: str claim_rid: str status: str confidence: float endorse_count: int dispute_count: int abstain_count: int supporting_evidence: int challenging_evidence: int net_sentiment: float # --- Claims --- @router.post("", response_model=ClaimResponse, status_code=201) async def create_claim(data: ClaimCreate): pool = get_pool() claim_id = str(uuid.uuid4()) rid = str(SporeClaim(claim_id)) row = await pool.fetchrow( """ INSERT INTO claims (id, rid, proposer_rid, content, confidence, anchor_type, metadata) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb) RETURNING id, rid, proposer_rid, content, status, confidence, anchor_type, metadata """, uuid.UUID(claim_id), rid, data.proposer_rid, data.content, data.confidence, data.anchor_type, json.dumps(data.metadata), ) await _log_event(pool, rid, "claim.created", {"content": data.content[:200]}) return _row_dict(row) @router.get("", response_model=list[ClaimResponse]) async def list_claims( status: str | None = None, proposer_rid: str | None = None, limit: int = Query(default=50, le=200), offset: int = 0, ): pool = get_pool() conditions, params = [], [] idx = 1 if status: conditions.append(f"status = ${idx}") params.append(status) idx += 1 if proposer_rid: conditions.append(f"proposer_rid = ${idx}") params.append(proposer_rid) idx += 1 where = "WHERE " + " AND ".join(conditions) if conditions else "" rows = await pool.fetch( f"SELECT * FROM claims {where} ORDER BY created_at DESC LIMIT ${idx} OFFSET ${idx+1}", *params, limit, offset, ) return [_row_dict(r) for r in rows] @router.get("/{claim_id}", response_model=ClaimResponse) async def get_claim(claim_id: str): pool = get_pool() row = await pool.fetchrow("SELECT * FROM claims WHERE id = $1", uuid.UUID(claim_id)) if not row: raise HTTPException(404, "Claim not found") return _row_dict(row) @router.patch("/{claim_id}/status") async def transition_claim_status(claim_id: str, new_status: str): pool = get_pool() row = await pool.fetchrow("SELECT status, rid FROM claims WHERE id = $1", uuid.UUID(claim_id)) if not row: raise HTTPException(404, "Claim not found") current = row["status"] if new_status not in VALID_TRANSITIONS.get(current, ()): raise HTTPException( 422, f"Cannot transition from '{current}' to '{new_status}'. " f"Valid: {VALID_TRANSITIONS.get(current, ())}", ) await pool.execute( "UPDATE claims SET status = $1, updated_at = now() WHERE id = $2", new_status, uuid.UUID(claim_id), ) await _log_event(pool, row["rid"], "claim.status_changed", {"from": current, "to": new_status}) return {"status": new_status, "previous": current} @router.get("/{claim_id}/strength", response_model=ClaimStrengthResponse) async def get_claim_strength(claim_id: str): pool = get_pool() # Refresh materialized view await pool.execute("REFRESH MATERIALIZED VIEW CONCURRENTLY claim_strength") row = await pool.fetchrow( "SELECT * FROM claim_strength WHERE claim_id = $1", uuid.UUID(claim_id) ) if not row: raise HTTPException(404, "Claim not found") d = dict(row) d["claim_id"] = str(d["claim_id"]) return d # --- Evidence --- @router.post("/{claim_id}/evidence", response_model=EvidenceResponse, status_code=201) async def add_evidence(claim_id: str, data: EvidenceCreate): if data.relation not in ("supports", "challenges"): raise HTTPException(422, "relation must be 'supports' or 'challenges'") pool = get_pool() # Verify claim exists claim = await pool.fetchrow("SELECT rid FROM claims WHERE id = $1", uuid.UUID(claim_id)) if not claim: raise HTTPException(404, "Claim not found") eid = str(uuid.uuid4()) rid = str(SporeEvidence(eid)) row = await pool.fetchrow( """ INSERT INTO evidence (id, rid, claim_id, relation, body, provenance) VALUES ($1, $2, $3, $4, $5, $6::jsonb) RETURNING id, rid, claim_id, relation, body, provenance """, uuid.UUID(eid), rid, uuid.UUID(claim_id), data.relation, data.body, json.dumps(data.provenance), ) await _log_event(pool, claim["rid"], f"evidence.{data.relation}", {"evidence_rid": rid}) return _evidence_dict(row) @router.get("/{claim_id}/evidence", response_model=list[EvidenceResponse]) async def list_evidence(claim_id: str, relation: str | None = None): pool = get_pool() if relation: rows = await pool.fetch( "SELECT * FROM evidence WHERE claim_id = $1 AND relation = $2 ORDER BY created_at", uuid.UUID(claim_id), relation, ) else: rows = await pool.fetch( "SELECT * FROM evidence WHERE claim_id = $1 ORDER BY created_at", uuid.UUID(claim_id), ) return [_evidence_dict(r) for r in rows] # --- Attestations --- @router.post("/{claim_id}/attestations", response_model=AttestationResponse, status_code=201) async def attest_claim(claim_id: str, data: AttestationCreate): if data.verdict not in ("endorse", "dispute", "abstain"): raise HTTPException(422, "verdict must be 'endorse', 'dispute', or 'abstain'") pool = get_pool() claim = await pool.fetchrow("SELECT rid FROM claims WHERE id = $1", uuid.UUID(claim_id)) if not claim: raise HTTPException(404, "Claim not found") aid = str(uuid.uuid4()) rid = str(SporeAttestation(aid)) try: row = await pool.fetchrow( """ INSERT INTO attestations (id, rid, claim_id, attester_rid, verdict, strength, reasoning) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id, rid, claim_id, attester_rid, verdict, strength, reasoning """, uuid.UUID(aid), rid, uuid.UUID(claim_id), data.attester_rid, data.verdict, data.strength, data.reasoning, ) except Exception as e: if "unique" in str(e).lower(): raise HTTPException(409, f"Attester '{data.attester_rid}' already attested this claim") raise await _log_event(pool, claim["rid"], f"attestation.{data.verdict}", {"attester": data.attester_rid, "strength": data.strength}) return _attestation_dict(row) @router.get("/{claim_id}/attestations", response_model=list[AttestationResponse]) async def list_attestations(claim_id: str, verdict: str | None = None): pool = get_pool() if verdict: rows = await pool.fetch( "SELECT * FROM attestations WHERE claim_id = $1 AND verdict = $2 ORDER BY created_at", uuid.UUID(claim_id), verdict, ) else: rows = await pool.fetch( "SELECT * FROM attestations WHERE claim_id = $1 ORDER BY created_at", uuid.UUID(claim_id), ) return [_attestation_dict(r) for r in rows] # --- Helpers --- def _row_dict(row) -> dict: d = dict(row) d["id"] = str(d["id"]) if isinstance(d.get("metadata"), str): d["metadata"] = json.loads(d["metadata"]) return d def _evidence_dict(row) -> dict: d = dict(row) d["id"] = str(d["id"]) d["claim_id"] = str(d["claim_id"]) if isinstance(d.get("provenance"), str): d["provenance"] = json.loads(d["provenance"]) return d def _attestation_dict(row) -> dict: d = dict(row) d["id"] = str(d["id"]) d["claim_id"] = str(d["claim_id"]) return d async def _log_event(pool, entity_rid: str, event_kind: str, payload: dict): await pool.execute( "INSERT INTO events (entity_rid, event_kind, payload) VALUES ($1, $2, $3::jsonb)", entity_rid, event_kind, json.dumps(payload), )