abinazebinoy commited on
Commit
d5dc03e
·
1 Parent(s): 3cef2e1

feat(phase-32): add api/routes/resolve.py with 4 endpoints

Browse files

GET /resolve?name=...&kind=person -- normalise + canonicalise a single name
GET /resolve/alias?name=... -- O(1) lookup in pre-built alias graph
POST /resolve/batch -- resolve list of records (max 500), merge duplicates
GET /resolve/stats -- alias graph statistics

The /resolve/alias and /resolve/stats endpoints share the _alias_graph singleton
loaded at startup from data/processed/alias_graph.json. The /resolve/batch
endpoint creates a temporary resolver for the request at the caller's threshold.

Files changed (1) hide show
  1. api/routes/resolve.py +181 -0
api/routes/resolve.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BharatGraph - Phase 32: Entity Resolution API
3
+ GET /resolve?name=Sh.+Ram+Kumar&kind=person
4
+ -> canonical_id, normalised_name, score, aliases
5
+
6
+ GET /resolve/alias?name=Ram+Kumar
7
+ -> canonical_id from alias graph (O(1) lookup)
8
+
9
+ POST /resolve/batch
10
+ -> resolve a list of names against each other (cross-dataset matching)
11
+
12
+ Pure ASCII.
13
+ """
14
+ from fastapi import APIRouter, Query, HTTPException, Depends
15
+ from typing import Optional, List
16
+ from pydantic import BaseModel
17
+ from loguru import logger
18
+
19
+ from processing.entity_resolver_v2 import EntityResolverV2, normalise_indian_name
20
+ from processing.canonical_id import canonical_id
21
+ from processing.alias_graph import AliasGraph
22
+
23
+ router = APIRouter(prefix="/resolve", tags=["resolve"])
24
+
25
+ # Module-level resolver (shared across requests)
26
+ _resolver = EntityResolverV2(threshold=0.82)
27
+
28
+
29
+ # ---- Pydantic models ---------------------------------------------------
30
+
31
+ class ResolveRecord(BaseModel):
32
+ name: str
33
+ kind: str = "person" # "person" or "company"
34
+ cin: Optional[str] = None
35
+ pan: Optional[str] = None
36
+ gstin: Optional[str] = None
37
+ source: Optional[str] = None
38
+
39
+
40
+ class BatchResolveRequest(BaseModel):
41
+ records: List[ResolveRecord]
42
+ threshold: float = 0.82
43
+ name_field: str = "name"
44
+
45
+
46
+ class ResolveResponse(BaseModel):
47
+ input_name: str
48
+ normalised_name: str
49
+ canonical_id: str
50
+ kind: str
51
+ aliases: List[dict] = []
52
+ score: float = 1.0
53
+ note: str = ""
54
+
55
+
56
+ class AliasLookupResponse(BaseModel):
57
+ input_name: str
58
+ canonical_id: str
59
+ found: bool
60
+
61
+
62
+ # ---- Routes ------------------------------------------------------------
63
+
64
+ @router.get("", response_model=ResolveResponse)
65
+ def resolve_name(
66
+ name: str = Query(..., min_length=1, max_length=300,
67
+ description="Name to resolve (person or company)"),
68
+ kind: str = Query("person", description="'person' or 'company'"),
69
+ ):
70
+ """
71
+ Normalise and canonicalise a single name.
72
+ Returns the normalised form, canonical SHA-256 ID, and any aliases
73
+ found in the current in-memory resolver state.
74
+
75
+ Example:
76
+ GET /resolve?name=Sh.+Ram+Kumar&kind=person
77
+ """
78
+ if kind not in ("person", "company"):
79
+ raise HTTPException(status_code=400,
80
+ detail="kind must be 'person' or 'company'")
81
+ try:
82
+ norm = normalise_indian_name(name, kind)
83
+ cid = canonical_id(norm)
84
+ return ResolveResponse(
85
+ input_name = name,
86
+ normalised_name = norm,
87
+ canonical_id = cid,
88
+ kind = kind,
89
+ score = 1.0,
90
+ note = "Direct normalisation -- no cross-record matching performed",
91
+ )
92
+ except Exception as e:
93
+ logger.error(f"[Resolve] Error: {type(e).__name__}")
94
+ raise HTTPException(status_code=500, detail="Resolution failed")
95
+
96
+
97
+ @router.get("/alias", response_model=AliasLookupResponse)
98
+ def alias_lookup(
99
+ name: str = Query(..., min_length=1, max_length=300,
100
+ description="Name variant to look up in alias graph"),
101
+ ):
102
+ """
103
+ O(1) lookup in the pre-built alias graph.
104
+ Returns the canonical_id if this name variant has been seen before,
105
+ empty string if not found.
106
+
107
+ The alias graph is built by the pipeline after each run and loaded
108
+ at startup. Fast for any name seen during ingestion.
109
+
110
+ Example:
111
+ GET /resolve/alias?name=RAHUL+KUMAR
112
+ """
113
+ try:
114
+ # Import the singleton from main to share state
115
+ from api.main import _alias_graph
116
+ cid = _alias_graph.resolve(name)
117
+ return AliasLookupResponse(
118
+ input_name = name,
119
+ canonical_id = cid,
120
+ found = bool(cid),
121
+ )
122
+ except ImportError:
123
+ return AliasLookupResponse(
124
+ input_name = name,
125
+ canonical_id = "",
126
+ found = False,
127
+ )
128
+ except Exception as e:
129
+ logger.error(f"[Resolve/alias] Error: {type(e).__name__}")
130
+ raise HTTPException(status_code=500, detail="Alias lookup failed")
131
+
132
+
133
+ @router.post("/batch")
134
+ def batch_resolve(body: BatchResolveRequest):
135
+ """
136
+ Resolve a list of records against each other.
137
+ Identifies duplicates and assigns canonical IDs.
138
+ Maximum 500 records per request (HuggingFace CPU limit).
139
+
140
+ Returns merged canonical records with alias lists.
141
+ """
142
+ if len(body.records) > 500:
143
+ raise HTTPException(status_code=400,
144
+ detail="Maximum 500 records per batch request")
145
+ try:
146
+ resolver = EntityResolverV2(threshold=body.threshold)
147
+ raw = []
148
+ for rec in body.records:
149
+ d = {"name": rec.name, "kind": rec.kind}
150
+ if rec.cin: d["cin"] = rec.cin
151
+ if rec.pan: d["pan"] = rec.pan
152
+ if rec.gstin: d["gstin"] = rec.gstin
153
+ if rec.source: d["_source"] = rec.source
154
+ raw.append(d)
155
+
156
+ resolved = resolver.resolve_dataset(raw, name_field="name")
157
+ merged = len(body.records) - len(resolved)
158
+
159
+ return {
160
+ "input_count": len(body.records),
161
+ "canonical_count": len(resolved),
162
+ "merged_count": merged,
163
+ "threshold": body.threshold,
164
+ "records": resolved,
165
+ }
166
+ except Exception as e:
167
+ logger.error(f"[Resolve/batch] Error: {type(e).__name__}")
168
+ raise HTTPException(status_code=500, detail="Batch resolution failed")
169
+
170
+
171
+ @router.get("/stats")
172
+ def resolve_stats():
173
+ """
174
+ Return alias graph statistics: how many aliases are indexed,
175
+ how many unique canonical IDs, avg aliases per entity.
176
+ """
177
+ try:
178
+ from api.main import _alias_graph
179
+ return {"alias_graph": _alias_graph.stats()}
180
+ except ImportError:
181
+ return {"alias_graph": {"total_aliases": 0, "unique_canonical_ids": 0}}