178 lines
6.0 KiB
Python
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}
|