Initial travel knowledge graph release
This commit is contained in:
177
app/api/entities.py
Normal file
177
app/api/entities.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user