Spaces:
Running
Running
| """ | |
| 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 ------------------------------------------------------------ | |
| 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") | |
| 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") | |
| 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") | |
| 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}} | |