315 lines
9.3 KiB
Python
315 lines
9.3 KiB
Python
"""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),
|
|
)
|