abinazebinoy commited on
Commit
c4cc55b
·
1 Parent(s): f818de0

feat(ai/forensics):complete — Revolving Door + TBML

Browse files

- ai/forensics/revolving_door.py: career transition detector
Cooling-off violation: government role → private board < 365 days
Pre-employment benefit: company received contracts before appointment
Fallback-safe with sample data when DB unavailable
- ai/forensics/tbml_detector.py: trade-based transfer indicators
Price anomaly: contracts > 2.5 std-dev from entity mean
Subcontract loop: circular re-award chain detection via Neo4j cycles
Director-change window: directorship change within 90 days of contract
- api/routes/conflict.py: GET /conflict/revolving-door/{entity_id},
GET /conflict/tbml/{entity_id}
- api/main.py: conflict router registered

ai/forensics/revolving_door.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, date
5
+ from loguru import logger
6
+
7
+ COOLING_OFF_DAYS = 365 # 1 year minimum expected gap
8
+ BENEFIT_WINDOW = 730 # 2 years pre-appointment benefit window
9
+
10
+
11
+ class RevolvingDoorDetector:
12
+ """
13
+ Detects career transitions from regulatory/government roles
14
+ to private-sector boards and companies that were regulated
15
+ or benefited from the official's decisions.
16
+ """
17
+
18
+ def analyze(self, entity_id: str, entity_name: str,
19
+ driver=None) -> dict:
20
+ logger.info(f"[RevolvingDoor] Analyzing {entity_name}")
21
+
22
+ transitions = self._fetch_transitions(entity_id, driver)
23
+ findings = []
24
+ positive = []
25
+
26
+ for t in transitions:
27
+ gap_days = self._day_gap(
28
+ t.get("left_date",""), t.get("joined_date","")
29
+ )
30
+ if gap_days is None:
31
+ continue
32
+
33
+ if gap_days < COOLING_OFF_DAYS:
34
+ findings.append({
35
+ "type": "cooling_off_violation",
36
+ "severity": "HIGH" if gap_days < 180 else "MODERATE",
37
+ "description": (
38
+ f"{entity_name} moved from {t.get('from_role','?')} "
39
+ f"at {t.get('from_org','?')} to {t.get('to_role','?')} "
40
+ f"at {t.get('to_org','?')} in {gap_days} days — "
41
+ f"below the expected {COOLING_OFF_DAYS}-day cooling-off period."
42
+ ),
43
+ "evidence": [
44
+ f"Left: {t.get('from_org')} on {t.get('left_date')}",
45
+ f"Joined: {t.get('to_org')} on {t.get('joined_date')}",
46
+ f"Gap: {gap_days} days",
47
+ ],
48
+ })
49
+
50
+ pre_benefit = self._check_pre_employment_benefit(
51
+ t, entity_id, driver
52
+ )
53
+ if pre_benefit:
54
+ findings.append(pre_benefit)
55
+
56
+ if not findings:
57
+ positive.append(
58
+ "No cooling-off violations or pre-employment benefit patterns "
59
+ "detected in available career transition data."
60
+ )
61
+
62
+ logger.success(
63
+ f"[RevolvingDoor] {entity_name}: "
64
+ f"{len(transitions)} transitions, {len(findings)} findings"
65
+ )
66
+ return {
67
+ "entity_id": entity_id,
68
+ "entity_name": entity_name,
69
+ "transitions_found": len(transitions),
70
+ "findings": findings,
71
+ "positive": positive,
72
+ "analyzed_at": datetime.now().isoformat(),
73
+ }
74
+
75
+ def _fetch_transitions(self, entity_id: str, driver) -> list:
76
+ if not driver:
77
+ return [
78
+ {
79
+ "from_org": "Ministry of Finance",
80
+ "from_role": "Joint Secretary",
81
+ "left_date": "2021-03-31",
82
+ "to_org": "HDFC Bank",
83
+ "to_role": "Independent Director",
84
+ "joined_date": "2021-07-15",
85
+ "entity_type": "regulator_to_private",
86
+ },
87
+ ]
88
+ try:
89
+ with driver.session() as s:
90
+ rows = s.run(
91
+ """
92
+ MATCH (p {id:$id})-[:WORKED_AT]->(org)
93
+ RETURN org.name AS from_org, org.role AS from_role,
94
+ org.left_date AS left_date, org.type AS org_type
95
+ LIMIT 20
96
+ """, id=entity_id
97
+ ).data()
98
+ return [dict(r) for r in rows]
99
+ except Exception:
100
+ return []
101
+
102
+ def _check_pre_employment_benefit(self, transition: dict,
103
+ entity_id: str, driver) -> dict | None:
104
+ if not driver:
105
+ return None
106
+ try:
107
+ to_org = transition.get("to_org","")
108
+ with driver.session() as s:
109
+ row = s.run(
110
+ """
111
+ MATCH (c:Company {name:$name})-[:WON_CONTRACT]->(ct:Contract)
112
+ WHERE ct.order_date >= $start AND ct.order_date <= $end
113
+ RETURN count(ct) AS n, sum(ct.amount_crore) AS total
114
+ """,
115
+ name=to_org,
116
+ start=transition.get("left_date","2000-01-01"),
117
+ end=transition.get("joined_date","2099-01-01"),
118
+ ).single()
119
+ if row and row.get("n",0) >= 2:
120
+ return {
121
+ "type": "pre_employment_benefit",
122
+ "severity": "HIGH",
123
+ "description": (
124
+ f"{to_org} received {row['n']} government contracts "
125
+ f"worth Rs {row.get('total',0):.1f} Cr during the "
126
+ f"period between the official's departure and board appointment."
127
+ ),
128
+ "evidence": [
129
+ f"Contracts: {row['n']}",
130
+ f"Total: Rs {row.get('total',0):.1f} Cr",
131
+ f"Window: {transition.get('left_date')} → {transition.get('joined_date')}",
132
+ ],
133
+ }
134
+ except Exception:
135
+ pass
136
+ return None
137
+
138
+ def _day_gap(self, date_a: str, date_b: str):
139
+ try:
140
+ d1 = date.fromisoformat(date_a[:10])
141
+ d2 = date.fromisoformat(date_b[:10])
142
+ return abs((d2 - d1).days)
143
+ except Exception:
144
+ return None
145
+
146
+
147
+ if __name__ == "__main__":
148
+ print("=" * 55)
149
+ print("BharatGraph — Revolving Door Test")
150
+ print("=" * 55)
151
+ r = RevolvingDoorDetector()
152
+ result = r.analyze("pol_001", "Test Official", driver=None)
153
+ print(f"\n Transitions: {result['transitions_found']}")
154
+ print(f" Findings: {len(result['findings'])}")
155
+ for f in result["findings"]:
156
+ print(f" [{f['severity']}] {f['type']}: {f['description'][:70]}")
157
+ print("\nDone!")
ai/forensics/tbml_detector.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, sys, statistics
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
+ PRICE_ANOMALY_SIGMA = 2.5 # std dev threshold for price anomaly
8
+ SUBCONTRACT_DEPTH = 3 # max re-award chain considered suspicious
9
+
10
+
11
+ class TBMLDetector:
12
+ """
13
+ Trade-Based indicators of value transfer:
14
+ 1. Contract price anomaly vs category median
15
+ 2. Subcontract loop detection (A awards to B, B to C, C to A)
16
+ 3. Award-to-director-change window (director changed shortly after contract)
17
+ """
18
+
19
+ def analyze(self, entity_id: str, driver=None) -> dict:
20
+ logger.info(f"[TBML] Analyzing {entity_id}")
21
+
22
+ findings = []
23
+ positive = []
24
+
25
+ price_findings = self._price_anomaly(entity_id, driver)
26
+ loop_findings = self._subcontract_loop(entity_id, driver)
27
+ window_findings = self._award_director_window(entity_id, driver)
28
+
29
+ findings.extend(price_findings)
30
+ findings.extend(loop_findings)
31
+ findings.extend(window_findings)
32
+
33
+ if not findings:
34
+ positive.append(
35
+ "Trade-based transfer analysis found no significant anomalies "
36
+ "in contract pricing, subcontracting patterns, or director changes."
37
+ )
38
+
39
+ logger.success(
40
+ f"[TBML] {entity_id}: {len(findings)} findings"
41
+ )
42
+ return {
43
+ "entity_id": entity_id,
44
+ "findings": findings,
45
+ "positive": positive,
46
+ "analyzed_at":datetime.now().isoformat(),
47
+ }
48
+
49
+ def _price_anomaly(self, entity_id: str, driver) -> list:
50
+ contracts = self._fetch_contracts(entity_id, driver)
51
+ if len(contracts) < 3:
52
+ return []
53
+
54
+ amounts = [float(c.get("amount_crore") or 0) for c in contracts]
55
+ amounts = [a for a in amounts if a > 0]
56
+ if len(amounts) < 3:
57
+ return []
58
+
59
+ mean = statistics.mean(amounts)
60
+ stdev = statistics.stdev(amounts)
61
+ if stdev == 0:
62
+ return []
63
+
64
+ outliers = [
65
+ c for c in contracts
66
+ if abs(float(c.get("amount_crore") or 0) - mean) > PRICE_ANOMALY_SIGMA * stdev
67
+ ]
68
+ if not outliers:
69
+ return []
70
+
71
+ return [{
72
+ "type": "contract_price_anomaly",
73
+ "severity": "HIGH" if len(outliers) >= 2 else "MODERATE",
74
+ "description": (
75
+ f"{len(outliers)} contract(s) have values more than "
76
+ f"{PRICE_ANOMALY_SIGMA} standard deviations from the "
77
+ f"entity's contract average (Rs {mean:.1f} Cr). "
78
+ f"Abnormal contract pricing may indicate value inflation."
79
+ ),
80
+ "evidence": [
81
+ f"Contract Rs {float(c.get('amount_crore',0)):.1f} Cr "
82
+ f"vs mean Rs {mean:.1f} Cr (z={abs(float(c.get('amount_crore',0))-mean)/stdev:.1f}σ)"
83
+ for c in outliers[:3]
84
+ ],
85
+ }]
86
+
87
+ def _subcontract_loop(self, entity_id: str, driver) -> list:
88
+ if not driver:
89
+ return []
90
+ try:
91
+ with driver.session() as s:
92
+ rows = s.run(
93
+ """
94
+ MATCH path = (c1:Company)-[:SUBCONTRACTS_TO*2..4]->(c1)
95
+ WHERE any(n IN nodes(path) WHERE n.id = $id)
96
+ RETURN length(path) AS depth,
97
+ [n IN nodes(path) | n.name] AS loop_nodes
98
+ LIMIT 5
99
+ """, id=entity_id
100
+ ).data()
101
+ if rows:
102
+ return [{
103
+ "type": "subcontract_loop",
104
+ "severity": "HIGH",
105
+ "description": (
106
+ f"Circular subcontracting detected: "
107
+ f"{' → '.join((rows[0].get('loop_nodes') or [])[:4])}. "
108
+ f"Contract re-award loops are a structural indicator "
109
+ f"of artificial transaction chains."
110
+ ),
111
+ "evidence": [
112
+ f"Loop depth: {rows[0].get('depth')} hops"
113
+ ],
114
+ }]
115
+ except Exception:
116
+ pass
117
+ return []
118
+
119
+ def _award_director_window(self, entity_id: str, driver) -> list:
120
+ if not driver:
121
+ return []
122
+ try:
123
+ with driver.session() as s:
124
+ rows = s.run(
125
+ """
126
+ MATCH (p {id:$id})-[:DIRECTOR_OF]->(c:Company)
127
+ -[:WON_CONTRACT]->(ct:Contract)
128
+ WHERE ct.order_date IS NOT NULL
129
+ AND c.director_change_date IS NOT NULL
130
+ AND abs(duration.inDays(
131
+ date(ct.order_date),
132
+ date(c.director_change_date)
133
+ ).days) <= 90
134
+ RETURN c.name AS company, ct.order_date AS award,
135
+ c.director_change_date AS change_date
136
+ LIMIT 5
137
+ """, id=entity_id
138
+ ).data()
139
+ if rows:
140
+ return [{
141
+ "type": "director_change_near_award",
142
+ "severity": "MODERATE",
143
+ "description": (
144
+ f"Director change at {rows[0].get('company','?')} "
145
+ f"occurred within 90 days of contract award on "
146
+ f"{rows[0].get('award','?')}. Director substitution "
147
+ f"near contract award is a structural risk indicator."
148
+ ),
149
+ "evidence": [
150
+ f"Award: {r.get('award')} | Director change: {r.get('change_date')}"
151
+ for r in rows[:3]
152
+ ],
153
+ }]
154
+ except Exception:
155
+ pass
156
+ return []
157
+
158
+ def _fetch_contracts(self, entity_id: str, driver) -> list:
159
+ if not driver:
160
+ return [
161
+ {"id":"c1","amount_crore":12.0,"buyer_org":"MoRTH"},
162
+ {"id":"c2","amount_crore":11.5,"buyer_org":"MoRTH"},
163
+ {"id":"c3","amount_crore":89.0,"buyer_org":"MoRTH"},
164
+ {"id":"c4","amount_crore":13.0,"buyer_org":"MoRTH"},
165
+ ]
166
+ try:
167
+ with driver.session() as s:
168
+ rows = s.run(
169
+ """
170
+ MATCH (p {id:$id})-[:DIRECTOR_OF]->(c:Company)
171
+ -[:WON_CONTRACT]->(ct:Contract)
172
+ RETURN ct.id AS id, ct.amount_crore AS amount_crore,
173
+ ct.buyer_org AS buyer_org, ct.order_date AS date
174
+ LIMIT 50
175
+ """, id=entity_id
176
+ ).data()
177
+ return [dict(r) for r in rows]
178
+ except Exception:
179
+ return []
180
+
181
+
182
+ if __name__ == "__main__":
183
+ print("=" * 55)
184
+ print("BharatGraph — TBML Detector Test")
185
+ print("=" * 55)
186
+ t = TBMLDetector()
187
+ r = t.analyze("pol_001", driver=None)
188
+ print(f"\n Findings: {len(r['findings'])}")
189
+ for f in r["findings"]:
190
+ print(f" [{f['severity']}] {f['type']}: {f['description'][:70]}")
191
+ if r["positive"]:
192
+ print(f" Positive: {r['positive'][0][:70]}")
193
+ print("\nDone!")
api/main.py CHANGED
@@ -10,7 +10,7 @@ from fastapi.middleware.cors import CORSMiddleware
10
  from loguru import logger
11
 
12
  from api.dependencies import get_driver, close_driver
13
- from api.routes import search, profile, graph, risk, multilingual, export, admin, investigation, affidavit, biography, benami, sources, procurement
14
  from api.models import HealthResponse, StatsResponse
15
 
16
 
@@ -65,6 +65,7 @@ app.include_router(biography.router, tags=["Biography"])
65
  app.include_router(benami.router, tags=["Benami"])
66
  app.include_router(sources.router, tags=["Sources"])
67
  app.include_router(procurement.router, tags=["Procurement"])
 
68
 
69
 
70
  @app.get("/health", response_model=HealthResponse)
 
10
  from loguru import logger
11
 
12
  from api.dependencies import get_driver, close_driver
13
+ from api.routes import search, profile, graph, risk, multilingual, export, admin, investigation, affidavit, biography, benami, sources, procurement, conflict
14
  from api.models import HealthResponse, StatsResponse
15
 
16
 
 
65
  app.include_router(benami.router, tags=["Benami"])
66
  app.include_router(sources.router, tags=["Sources"])
67
  app.include_router(procurement.router, tags=["Procurement"])
68
+ app.include_router(conflict.router, tags=["Conflict"])
69
 
70
 
71
  @app.get("/health", response_model=HealthResponse)
api/routes/conflict.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ from api.dependencies import get_db
7
+ from ai.forensics.revolving_door import RevolvingDoorDetector
8
+ from ai.forensics.tbml_detector import TBMLDetector
9
+
10
+ router = APIRouter()
11
+ rev_door = RevolvingDoorDetector()
12
+ tbml = TBMLDetector()
13
+
14
+
15
+ @router.get("/conflict/revolving-door/{entity_id}")
16
+ def revolving_door(entity_id: str, driver=Depends(get_db)):
17
+ with driver.session() as s:
18
+ row = s.run(
19
+ "MATCH (n {id:$id}) RETURN n.name AS name", id=entity_id
20
+ ).single()
21
+ if not row:
22
+ raise HTTPException(status_code=404,
23
+ detail=f"Entity {entity_id} not found")
24
+ name = row.get("name") or entity_id
25
+ return rev_door.analyze(entity_id, name, driver=driver)
26
+
27
+
28
+ @router.get("/conflict/tbml/{entity_id}")
29
+ def tbml_analysis(entity_id: str, driver=Depends(get_db)):
30
+ return tbml.analyze(entity_id, driver=driver)