bharatgraph / api /routes /resolve.py
abinazebinoy's picture
feat(phase-32): add api/routes/resolve.py with 4 endpoints
d5dc03e
Raw
History Blame Contribute Delete
5.92 kB
"""
BharatGraph - Phase 32: Entity Resolution API
GET /resolve?name=Sh.+Ram+Kumar&kind=person
-> canonical_id, normalised_name, score, aliases
GET /resolve/alias?name=Ram+Kumar
-> canonical_id from alias graph (O(1) lookup)
POST /resolve/batch
-> resolve a list of names against each other (cross-dataset matching)
Pure ASCII.
"""
from fastapi import APIRouter, Query, HTTPException, Depends
from typing import Optional, List
from pydantic import BaseModel
from loguru import logger
from processing.entity_resolver_v2 import EntityResolverV2, normalise_indian_name
from processing.canonical_id import canonical_id
from processing.alias_graph import AliasGraph
router = APIRouter(prefix="/resolve", tags=["resolve"])
# Module-level resolver (shared across requests)
_resolver = EntityResolverV2(threshold=0.82)
# ---- Pydantic models ---------------------------------------------------
class ResolveRecord(BaseModel):
name: str
kind: str = "person" # "person" or "company"
cin: Optional[str] = None
pan: Optional[str] = None
gstin: Optional[str] = None
source: Optional[str] = None
class BatchResolveRequest(BaseModel):
records: List[ResolveRecord]
threshold: float = 0.82
name_field: str = "name"
class ResolveResponse(BaseModel):
input_name: str
normalised_name: str
canonical_id: str
kind: str
aliases: List[dict] = []
score: float = 1.0
note: str = ""
class AliasLookupResponse(BaseModel):
input_name: str
canonical_id: str
found: bool
# ---- Routes ------------------------------------------------------------
@router.get("", response_model=ResolveResponse)
def resolve_name(
name: str = Query(..., min_length=1, max_length=300,
description="Name to resolve (person or company)"),
kind: str = Query("person", description="'person' or 'company'"),
):
"""
Normalise and canonicalise a single name.
Returns the normalised form, canonical SHA-256 ID, and any aliases
found in the current in-memory resolver state.
Example:
GET /resolve?name=Sh.+Ram+Kumar&kind=person
"""
if kind not in ("person", "company"):
raise HTTPException(status_code=400,
detail="kind must be 'person' or 'company'")
try:
norm = normalise_indian_name(name, kind)
cid = canonical_id(norm)
return ResolveResponse(
input_name = name,
normalised_name = norm,
canonical_id = cid,
kind = kind,
score = 1.0,
note = "Direct normalisation -- no cross-record matching performed",
)
except Exception as e:
logger.error(f"[Resolve] Error: {type(e).__name__}")
raise HTTPException(status_code=500, detail="Resolution failed")
@router.get("/alias", response_model=AliasLookupResponse)
def alias_lookup(
name: str = Query(..., min_length=1, max_length=300,
description="Name variant to look up in alias graph"),
):
"""
O(1) lookup in the pre-built alias graph.
Returns the canonical_id if this name variant has been seen before,
empty string if not found.
The alias graph is built by the pipeline after each run and loaded
at startup. Fast for any name seen during ingestion.
Example:
GET /resolve/alias?name=RAHUL+KUMAR
"""
try:
# Import the singleton from main to share state
from api.main import _alias_graph
cid = _alias_graph.resolve(name)
return AliasLookupResponse(
input_name = name,
canonical_id = cid,
found = bool(cid),
)
except ImportError:
return AliasLookupResponse(
input_name = name,
canonical_id = "",
found = False,
)
except Exception as e:
logger.error(f"[Resolve/alias] Error: {type(e).__name__}")
raise HTTPException(status_code=500, detail="Alias lookup failed")
@router.post("/batch")
def batch_resolve(body: BatchResolveRequest):
"""
Resolve a list of records against each other.
Identifies duplicates and assigns canonical IDs.
Maximum 500 records per request (HuggingFace CPU limit).
Returns merged canonical records with alias lists.
"""
if len(body.records) > 500:
raise HTTPException(status_code=400,
detail="Maximum 500 records per batch request")
try:
resolver = EntityResolverV2(threshold=body.threshold)
raw = []
for rec in body.records:
d = {"name": rec.name, "kind": rec.kind}
if rec.cin: d["cin"] = rec.cin
if rec.pan: d["pan"] = rec.pan
if rec.gstin: d["gstin"] = rec.gstin
if rec.source: d["_source"] = rec.source
raw.append(d)
resolved = resolver.resolve_dataset(raw, name_field="name")
merged = len(body.records) - len(resolved)
return {
"input_count": len(body.records),
"canonical_count": len(resolved),
"merged_count": merged,
"threshold": body.threshold,
"records": resolved,
}
except Exception as e:
logger.error(f"[Resolve/batch] Error: {type(e).__name__}")
raise HTTPException(status_code=500, detail="Batch resolution failed")
@router.get("/stats")
def resolve_stats():
"""
Return alias graph statistics: how many aliases are indexed,
how many unique canonical IDs, avg aliases per entity.
"""
try:
from api.main import _alias_graph
return {"alias_graph": _alias_graph.stats()}
except ImportError:
return {"alias_graph": {"total_aliases": 0, "unique_canonical_ids": 0}}