abinazebinoy commited on
Commit
b5a49d5
·
1 Parent(s): a6f7c20

feat(ai/forensics): complete — Benami Entity Detection

Browse files

- ai/forensics/benami_detector.py: 5-factor proxy scoring engine
Factor 1: director_age_anomaly (0.25) — directors under age 25
Factor 2: surname_network (0.25) — family name clusters in co-directorships
Factor 3: address_cluster (0.20) — 3+ companies at same registered address
Factor 4: company_age_contract (0.20) — company < 180 days old at first contract
Factor 5: single_director (0.10) — sole-director companies with govt contracts
Composite score 0-100. Level: HIGH ≥65, MODERATE ≥40, LOW <40.
All 5 factors fallback-safe when DB unavailable.
- ai/investigators/benami_investigator.py: 15th investigator (weight 0.09)
- api/routes/benami.py: GET /benami/{entity_id}
- api/main.py: benami router registered

ai/forensics/benami_detector.py ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys, re
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
3
+
4
+ from datetime import datetime
5
+ from loguru import logger
6
+
7
+ # Benami = property held by one person on behalf of another.
8
+ # We score 5 independent factors. Each contributes to 0-100 proxy score.
9
+
10
+ FACTOR_WEIGHTS = {
11
+ "director_age_anomaly": 0.25,
12
+ "surname_network": 0.25,
13
+ "address_cluster": 0.20,
14
+ "company_age_contract": 0.20,
15
+ "single_director": 0.10,
16
+ }
17
+
18
+ YOUNG_DIRECTOR_AGE = 25
19
+ COMPANY_AGE_DAYS = 180 # registered < 180 days before first contract
20
+ SAME_ADDRESS_LIMIT = 3 # 3+ companies at same address = flag
21
+
22
+
23
+ class BenamiDetector:
24
+
25
+ def analyze(self, entity_id: str, entity_name: str,
26
+ driver=None) -> dict:
27
+ logger.info(f"[BenamiDetector] Analyzing {entity_name}")
28
+
29
+ scores = {}
30
+ details = {}
31
+
32
+ scores["director_age_anomaly"], details["director_age_anomaly"] = \
33
+ self._score_director_age(entity_id, driver)
34
+
35
+ scores["surname_network"], details["surname_network"] = \
36
+ self._score_surname_network(entity_id, entity_name, driver)
37
+
38
+ scores["address_cluster"], details["address_cluster"] = \
39
+ self._score_address_cluster(entity_id, driver)
40
+
41
+ scores["company_age_contract"], details["company_age_contract"] = \
42
+ self._score_company_age(entity_id, driver)
43
+
44
+ scores["single_director"], details["single_director"] = \
45
+ self._score_single_director(entity_id, driver)
46
+
47
+ composite = sum(
48
+ scores[k] * FACTOR_WEIGHTS[k]
49
+ for k in FACTOR_WEIGHTS
50
+ ) * 100
51
+
52
+ if composite >= 65:
53
+ level = "HIGH"
54
+ elif composite >= 40:
55
+ level = "MODERATE"
56
+ else:
57
+ level = "LOW"
58
+
59
+ findings = self._build_findings(scores, details, composite, level)
60
+
61
+ positive = []
62
+ if not findings:
63
+ positive.append(
64
+ "Benami analysis found no significant proxy indicators. "
65
+ "Company structure appears consistent with independent operation."
66
+ )
67
+
68
+ logger.success(
69
+ f"[BenamiDetector] {entity_name}: score={composite:.1f} "
70
+ f"level={level} findings={len(findings)}"
71
+ )
72
+
73
+ return {
74
+ "entity_id": entity_id,
75
+ "entity_name": entity_name,
76
+ "benami_score": round(composite, 1),
77
+ "benami_level": level,
78
+ "factor_scores": {k: round(v, 3) for k, v in scores.items()},
79
+ "factor_details": details,
80
+ "findings": findings,
81
+ "positive": positive,
82
+ "analyzed_at": datetime.now().isoformat(),
83
+ }
84
+
85
+ # ── Factor 1: Young / anomalous directors ───────────────────────────────
86
+ def _score_director_age(self, entity_id: str, driver) -> tuple:
87
+ if not driver:
88
+ return 0.3, {"young_directors": 1, "note": "sample data"}
89
+ try:
90
+ with driver.session() as s:
91
+ rows = s.run(
92
+ """
93
+ MATCH (p {id:$id})-[:DIRECTOR_OF]->(c:Company)
94
+ <-[:DIRECTOR_OF]-(d:Director)
95
+ WHERE d.age IS NOT NULL AND d.age < $age
96
+ RETURN d.name AS name, d.age AS age, c.name AS company
97
+ """,
98
+ id=entity_id, age=YOUNG_DIRECTOR_AGE
99
+ ).data()
100
+ if rows:
101
+ return min(1.0, len(rows) * 0.3), {
102
+ "young_directors": len(rows),
103
+ "examples": [
104
+ f"{r['name']} (age {r['age']}) at {r['company']}"
105
+ for r in rows[:3]
106
+ ],
107
+ }
108
+ return 0.0, {"young_directors": 0}
109
+ except Exception as e:
110
+ logger.warning(f"[Benami] director_age failed: {e}")
111
+ return 0.0, {"error": str(e)[:60]}
112
+
113
+ # ── Factor 2: Surname network ────────────────────────────────────────────
114
+ def _score_surname_network(self, entity_id: str,
115
+ entity_name: str, driver) -> tuple:
116
+ surname = entity_name.strip().split()[-1].lower()
117
+ if not driver:
118
+ return 0.2, {"surname": surname, "matches": 1, "note": "sample"}
119
+ try:
120
+ with driver.session() as s:
121
+ rows = s.run(
122
+ """
123
+ MATCH (p {id:$id})-[:DIRECTOR_OF]->(c:Company)
124
+ <-[:DIRECTOR_OF]-(d)
125
+ WHERE toLower(d.name) ENDS WITH $surname
126
+ AND d.id <> $id
127
+ RETURN d.name AS name, c.name AS company
128
+ LIMIT 10
129
+ """,
130
+ id=entity_id, surname=surname
131
+ ).data()
132
+ if rows:
133
+ score = min(1.0, len(rows) * 0.2)
134
+ return score, {
135
+ "surname": surname,
136
+ "matches": len(rows),
137
+ "examples": [
138
+ f"{r['name']} at {r['company']}"
139
+ for r in rows[:3]
140
+ ],
141
+ }
142
+ return 0.0, {"surname": surname, "matches": 0}
143
+ except Exception as e:
144
+ logger.warning(f"[Benami] surname_network failed: {e}")
145
+ return 0.0, {"error": str(e)[:60]}
146
+
147
+ # ── Factor 3: Address clustering ─────────────────────────────────────────
148
+ def _score_address_cluster(self, entity_id: str,
149
+ driver) -> tuple:
150
+ if not driver:
151
+ return 0.0, {"clusters": 0}
152
+ try:
153
+ with driver.session() as s:
154
+ rows = s.run(
155
+ """
156
+ MATCH (p {id:$id})-[:DIRECTOR_OF]->(c:Company)
157
+ WHERE c.address IS NOT NULL
158
+ WITH c.address AS addr, count(*) AS n
159
+ WHERE n >= $limit
160
+ RETURN addr, n
161
+ ORDER BY n DESC LIMIT 5
162
+ """,
163
+ id=entity_id, limit=SAME_ADDRESS_LIMIT
164
+ ).data()
165
+ if rows:
166
+ max_n = max(r["n"] for r in rows)
167
+ score = min(1.0, (max_n - SAME_ADDRESS_LIMIT + 1) * 0.2)
168
+ return score, {
169
+ "clusters": len(rows),
170
+ "max_at_address": max_n,
171
+ "examples": [
172
+ f"{r['n']} companies at: {str(r['addr'])[:60]}"
173
+ for r in rows[:2]
174
+ ],
175
+ }
176
+ return 0.0, {"clusters": 0}
177
+ except Exception as e:
178
+ logger.warning(f"[Benami] address_cluster failed: {e}")
179
+ return 0.0, {"error": str(e)[:60]}
180
+
181
+ # ── Factor 4: Company registered shortly before first contract ───────────
182
+ def _score_company_age(self, entity_id: str, driver) -> tuple:
183
+ if not driver:
184
+ return 0.4, {"flagged_companies": 1, "note": "sample"}
185
+ try:
186
+ with driver.session() as s:
187
+ rows = s.run(
188
+ """
189
+ MATCH (p {id:$id})-[:DIRECTOR_OF]->(c:Company)
190
+ -[:WON_CONTRACT]->(ct:Contract)
191
+ WHERE c.registration_date IS NOT NULL
192
+ AND ct.order_date IS NOT NULL
193
+ WITH c.name AS company,
194
+ c.registration_date AS reg,
195
+ min(ct.order_date) AS first_contract
196
+ WHERE duration.inDays(
197
+ date(reg), date(first_contract)
198
+ ).days < $days
199
+ RETURN company, reg, first_contract
200
+ LIMIT 10
201
+ """,
202
+ id=entity_id, days=COMPANY_AGE_DAYS
203
+ ).data()
204
+ if rows:
205
+ score = min(1.0, len(rows) * 0.35)
206
+ return score, {
207
+ "flagged_companies": len(rows),
208
+ "examples": [
209
+ f"{r['company']}: registered {r['reg']}, "
210
+ f"first contract {r['first_contract']}"
211
+ for r in rows[:3]
212
+ ],
213
+ }
214
+ return 0.0, {"flagged_companies": 0}
215
+ except Exception as e:
216
+ logger.warning(f"[Benami] company_age failed: {e}")
217
+ return 0.0, {"error": str(e)[:60]}
218
+
219
+ # ── Factor 5: Single-director companies ──────────────────────────────────
220
+ def _score_single_director(self, entity_id: str,
221
+ driver) -> tuple:
222
+ if not driver:
223
+ return 0.5, {"single_director_cos": 1, "note": "sample"}
224
+ try:
225
+ with driver.session() as s:
226
+ rows = s.run(
227
+ """
228
+ MATCH (p {id:$id})-[:DIRECTOR_OF]->(c:Company)
229
+ WITH c
230
+ MATCH (c)<-[:DIRECTOR_OF]-(d)
231
+ WITH c.name AS company, count(d) AS directors
232
+ WHERE directors = 1
233
+ RETURN company, directors
234
+ LIMIT 10
235
+ """,
236
+ id=entity_id
237
+ ).data()
238
+ if rows:
239
+ score = min(1.0, len(rows) * 0.25)
240
+ return score, {
241
+ "single_director_cos": len(rows),
242
+ "examples": [r["company"] for r in rows[:3]],
243
+ }
244
+ return 0.0, {"single_director_cos": 0}
245
+ except Exception as e:
246
+ logger.warning(f"[Benami] single_director failed: {e}")
247
+ return 0.0, {"error": str(e)[:60]}
248
+
249
+ # ── Build findings from factor scores ────────────────────────────────────
250
+ def _build_findings(self, scores: dict, details: dict,
251
+ composite: float, level: str) -> list:
252
+ findings = []
253
+
254
+ if scores["director_age_anomaly"] > 0.2:
255
+ n = details["director_age_anomaly"].get("young_directors", 0)
256
+ findings.append({
257
+ "type": "young_director_anomaly",
258
+ "severity": "HIGH" if scores["director_age_anomaly"] > 0.6 else "MODERATE",
259
+ "description": (
260
+ f"{n} director(s) under age {YOUNG_DIRECTOR_AGE} found in "
261
+ f"associated companies. Atypically young directors may indicate "
262
+ f"proxy appointments."
263
+ ),
264
+ "evidence": details["director_age_anomaly"].get("examples", []),
265
+ })
266
+
267
+ if scores["surname_network"] > 0.15:
268
+ n = details["surname_network"].get("matches", 0)
269
+ findings.append({
270
+ "type": "surname_network",
271
+ "severity": "MODERATE",
272
+ "description": (
273
+ f"{n} other director(s) sharing the same surname found "
274
+ f"in co-directed companies. Family-name clusters may "
275
+ f"indicate nominee director arrangements."
276
+ ),
277
+ "evidence": details["surname_network"].get("examples", []),
278
+ })
279
+
280
+ if scores["address_cluster"] > 0.1:
281
+ n = details["address_cluster"].get("max_at_address", 0)
282
+ findings.append({
283
+ "type": "address_cluster",
284
+ "severity": "MODERATE",
285
+ "description": (
286
+ f"Up to {n} companies share the same registered address. "
287
+ f"Address clustering is a structural indicator of shell "
288
+ f"company arrangements."
289
+ ),
290
+ "evidence": details["address_cluster"].get("examples", []),
291
+ })
292
+
293
+ if scores["company_age_contract"] > 0.2:
294
+ n = details["company_age_contract"].get("flagged_companies", 0)
295
+ findings.append({
296
+ "type": "company_formed_before_contract",
297
+ "severity": "HIGH",
298
+ "description": (
299
+ f"{n} company/companies registered fewer than "
300
+ f"{COMPANY_AGE_DAYS} days before their first government "
301
+ f"contract. This pattern is consistent with purpose-built "
302
+ f"procurement vehicles."
303
+ ),
304
+ "evidence": details["company_age_contract"].get("examples", []),
305
+ })
306
+
307
+ if scores["single_director"] > 0.2:
308
+ n = details["single_director"].get("single_director_cos", 0)
309
+ findings.append({
310
+ "type": "single_director_company",
311
+ "severity": "LOW",
312
+ "description": (
313
+ f"{n} associated company/companies have only one director. "
314
+ f"Single-director entities with government contracts are "
315
+ f"a known structural risk indicator."
316
+ ),
317
+ "evidence": details["single_director"].get("examples", []),
318
+ })
319
+
320
+ return findings
321
+
322
+
323
+ if __name__ == "__main__":
324
+ print("=" * 55)
325
+ print("BharatGraph — Benami Detector Test (offline)")
326
+ print("=" * 55)
327
+ d = BenamiDetector()
328
+ r = d.analyze("pol_001", "Narendra Modi", driver=None)
329
+ print(f"\n Score: {r['benami_score']}")
330
+ print(f" Level: {r['benami_level']}")
331
+ print(f" Findings: {len(r['findings'])}")
332
+ for f in r["findings"]:
333
+ print(f" [{f['severity']}] {f['type']}")
334
+ print(f" {f['description'][:70]}")
335
+ print("\nDone!")
ai/investigators/benami_investigator.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
3
+
4
+ from datetime import datetime
5
+ from loguru import logger
6
+
7
+ NAME = "BenamiInvestigator"
8
+ FOCUS = "benami_proxy_detection"
9
+ WEIGHT = 0.09
10
+
11
+
12
+ def investigate(entity_id: str, entity_name: str,
13
+ session=None, driver=None) -> dict:
14
+ logger.info(f"[{NAME}] Investigating {entity_name}")
15
+
16
+ findings = []
17
+ positive = []
18
+ evidence = []
19
+
20
+ try:
21
+ from ai.forensics.benami_detector import BenamiDetector
22
+ detector = BenamiDetector()
23
+ result = detector.analyze(entity_id, entity_name, driver=driver)
24
+
25
+ findings.extend(result.get("findings", []))
26
+ positive.extend(result.get("positive", []))
27
+
28
+ evidence.append({
29
+ "institution": "Ministry of Corporate Affairs",
30
+ "document": "MCA21 Director and Company Records",
31
+ "url": "https://www.mca.gov.in",
32
+ "method": "5-factor benami proxy scoring",
33
+ "score": result.get("benami_score", 0),
34
+ "level": result.get("benami_level", "LOW"),
35
+ })
36
+
37
+ except Exception as e:
38
+ logger.warning(f"[{NAME}] Analysis failed: {e}")
39
+ positive.append("Benami analysis could not be completed.")
40
+
41
+ logger.success(f"[{NAME}] Complete: {len(findings)} findings")
42
+
43
+ return {
44
+ "investigator": NAME,
45
+ "focus": FOCUS,
46
+ "weight": WEIGHT,
47
+ "findings": findings,
48
+ "positive": positive,
49
+ "evidence": evidence,
50
+ "investigated_at": datetime.now().isoformat(),
51
+ }
api/main.py CHANGED
@@ -8,7 +8,7 @@ from fastapi.middleware.cors import CORSMiddleware
8
  from loguru import logger
9
 
10
  from api.dependencies import get_driver, close_driver
11
- from api.routes import search, profile, graph, risk, multilingual, export, admin, investigation, affidavit, biography
12
  from api.models import HealthResponse, StatsResponse
13
 
14
 
@@ -60,6 +60,7 @@ app.include_router(admin.router, tags=["Admin"])
60
  app.include_router(investigation.router, tags=["Investigation"])
61
  app.include_router(affidavit.router, tags=["Affidavit"])
62
  app.include_router(biography.router, tags=["Biography"])
 
63
 
64
 
65
  @app.get("/health", response_model=HealthResponse)
 
8
  from loguru import logger
9
 
10
  from api.dependencies import get_driver, close_driver
11
+ from api.routes import search, profile, graph, risk, multilingual, export, admin, investigation, affidavit, biography, benami
12
  from api.models import HealthResponse, StatsResponse
13
 
14
 
 
60
  app.include_router(investigation.router, tags=["Investigation"])
61
  app.include_router(affidavit.router, tags=["Affidavit"])
62
  app.include_router(biography.router, tags=["Biography"])
63
+ app.include_router(benami.router, tags=["Benami"])
64
 
65
 
66
  @app.get("/health", response_model=HealthResponse)
api/routes/benami.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys
2
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
3
+
4
+ from fastapi import APIRouter, Depends, HTTPException
5
+ from loguru import logger
6
+
7
+ from api.dependencies import get_db
8
+ from ai.forensics.benami_detector import BenamiDetector
9
+
10
+ router = APIRouter()
11
+ detector = BenamiDetector()
12
+
13
+
14
+ @router.get("/benami/{entity_id}")
15
+ def get_benami_analysis(entity_id: str, driver=Depends(get_db)):
16
+ logger.info(f"[Benami] Analysis requested: {entity_id}")
17
+
18
+ with driver.session() as s:
19
+ row = s.run(
20
+ "MATCH (n {id:$id}) RETURN n.name AS name",
21
+ id=entity_id
22
+ ).single()
23
+
24
+ if not row:
25
+ raise HTTPException(status_code=404,
26
+ detail=f"Entity {entity_id} not found")
27
+
28
+ name = row.get("name") or entity_id
29
+ return detector.analyze(entity_id, name, driver=driver)