Files
bxh/app/api/entities.py

178 lines
6.0 KiB
Python

"""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}