"""STEP 03 — Entities with field-level provenance & field decisions. Covers: - GET /entities — list candidate entities - GET /entities/:id — single entity detail - GET /entities/:id/provenance — field-level provenance - PATCH /entities/:id/field-decisions — field-level review decisions - POST /entities/:id/field-decisions/:field/use-source/:sourceId — use a specific source for a field - POST /entities/:id/merge-with/:targetId — merge entities (STEP 05) """ from fastapi import APIRouter, Depends, HTTPException from app.auth import CurrentUser from app.config import settings from app.contracts import FieldDecisionUpdate from app.db import ( get_candidate_entity, list_candidate_entities, update_candidate_entity, create_review_action, list_review_actions, ) from app.project_context import ProjectContext, get_project_context router = APIRouter() @router.get("/entities") async def _list( status: str | None = None, entity_type: str | None = None, limit: int = 50, offset: int = 0, context: ProjectContext = Depends(get_project_context), _user: CurrentUser = None, ): return await list_candidate_entities( context.tenant_id, context.project_id, status, entity_type, limit, offset, ) @router.get("/entities/{entity_id}") async def _get(entity_id: int, _user: CurrentUser = None): entity = await get_candidate_entity(entity_id) if not entity: raise HTTPException(404, "Entity not found") return entity @router.get("/entities/{entity_id}/provenance") async def _provenance(entity_id: int, _user: CurrentUser = None): """Return field-level provenance for this entity (core 4 fields).""" entity = await get_candidate_entity(entity_id) if not entity: raise HTTPException(404, "Entity not found") import json prov = entity.get("field_provenance_jsonb") if isinstance(prov, str): prov = json.loads(prov) return {"entity_id": entity_id, "natural_key": entity["natural_key"], "provenance": prov} @router.patch("/entities/{entity_id}/field-decisions") async def _field_decisions(entity_id: int, body: FieldDecisionUpdate, user: CurrentUser): """Record field-level review decisions.""" entity = await get_candidate_entity(entity_id) if not entity: raise HTTPException(404, "Entity not found") # Determine overall action decisions = body.field_decisions if "_overall" in decisions: overall = decisions["_overall"] else: approved_count = sum(1 for v in decisions.values() if v in ("approved", "use_source")) overall = "approved" if approved_count >= len(decisions) * 0.5 else "rejected" # Record review action await create_review_action({ "candidate_id": entity_id, "candidate_type": "entity", "action": overall, "actor": user["username"], "note": body.note, "field_decisions_jsonb": decisions, }) # Update entity status if approved if overall == "approved": await update_candidate_entity(entity_id, {"status": "approved"}) return {"entity_id": entity_id, "overall": overall, "field_decisions": decisions} @router.post("/entities/{entity_id}/field-decisions/{field}/use-source/{source_id}") async def _use_source(entity_id: int, field: str, source_id: int, user: CurrentUser): """Select a specific source's value for a field.""" entity = await get_candidate_entity(entity_id) if not entity: raise HTTPException(404, "Entity not found") import json prov = entity.get("field_provenance_jsonb") if isinstance(prov, str): prov = json.loads(prov) if field not in prov: raise HTTPException(404, f"Field '{field}' not in provenance") prov[field]["chosen_reason"] = f"采用来源 #{source_id}" prov[field]["verified_by"] = user["username"] await update_candidate_entity(entity_id, { "field_provenance_jsonb": json.dumps(prov), }) return {"entity_id": entity_id, "field": field, "source_id": source_id, "provenance": prov[field]} @router.get("/entities/{entity_id}/review-history") async def _review_history(entity_id: int, _user: CurrentUser = None): return await list_review_actions(entity_id) @router.post("/entities/{entity_id}/approve") async def _approve(entity_id: int, user: CurrentUser): entity = await get_candidate_entity(entity_id) if not entity: raise HTTPException(404, "Entity not found") await create_review_action({ "candidate_id": entity_id, "candidate_type": "entity", "action": "approved", "actor": user["username"], }) await update_candidate_entity(entity_id, {"status": "approved"}) return {"ok": True} @router.post("/entities/{entity_id}/reject") async def _reject(entity_id: int, note: str | None = None, user: CurrentUser = None): entity = await get_candidate_entity(entity_id) if not entity: raise HTTPException(404, "Entity not found") await create_review_action({ "candidate_id": entity_id, "candidate_type": "entity", "action": "rejected", "actor": user["username"] if user else "system", "note": note, }) await update_candidate_entity(entity_id, {"status": "rejected"}) return {"ok": True} @router.post("/entities/{entity_id}/merge-with/{target_id}") async def _merge(entity_id: int, target_id: int, note: str | None = None, user: CurrentUser = None): """Merge entity_id into target_id (STEP 05).""" source = await get_candidate_entity(entity_id) target = await get_candidate_entity(target_id) if not source or not target: raise HTTPException(404, "Entity not found") await create_review_action({ "candidate_id": entity_id, "candidate_type": "entity", "action": "merged", "actor": user["username"] if user else "system", "note": note or f"Merged into entity #{target_id}", }) await update_candidate_entity(entity_id, {"status": "merged"}) return {"merged": entity_id, "into": target_id, "ok": True}