abinazebinoy commited on
Commit
bac902f
·
1 Parent(s): 52a72c0

feat(ai): composite risk scoring engine with explainable factors

Browse files

- ai/indicators.py: five indicator functions with Neo4j queries
politician_company_overlap (weight 0.35)
contract_concentration (weight 0.25)
audit_mention_frequency (weight 0.20)
asset_growth_anomaly (weight 0.15)
criminal_case_presence (weight 0.05)
Each returns raw_score, weighted, evidence list, source citations
- ai/explainer.py: score_to_level(), generate_explanation(),
validate_language() enforcing FORBIDDEN_WORDS list
Never outputs: corrupt, bribe, guilty, fraud, suspect
- ai/risk_scorer.py: RiskScorer class, score_entity() per entity,
score_all_politicians(), score_all_companies(), write_scores_to_graph()
CLI: --entity-id ID | --all | --all --write-graph

Files changed (3) hide show
  1. ai/explainer.py +79 -0
  2. ai/indicators.py +188 -0
  3. ai/risk_scorer.py +218 -0
ai/explainer.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4
+
5
+
6
+ RISK_LEVELS = {
7
+ (0, 30): "LOW",
8
+ (31, 60): "MODERATE",
9
+ (61, 80): "HIGH",
10
+ (81, 100): "VERY_HIGH",
11
+ }
12
+
13
+ FORBIDDEN_WORDS = [
14
+ "corrupt", "bribe", "guilty", "criminal", "illegal",
15
+ "fraud", "suspect", "launder", "steal", "theft",
16
+ ]
17
+
18
+
19
+ def score_to_level(score: int) -> str:
20
+ for (lo, hi), level in RISK_LEVELS.items():
21
+ if lo <= score <= hi:
22
+ return level
23
+ return "UNKNOWN"
24
+
25
+
26
+ def generate_explanation(entity_name: str, score: int,
27
+ factors: list) -> str:
28
+ level = score_to_level(score)
29
+ active = [f for f in factors if f["raw_score"] > 0]
30
+
31
+ if score == 0 or not active:
32
+ return (
33
+ f"No structural indicators were identified for {entity_name} "
34
+ "in the current dataset. Limited data coverage may affect this result."
35
+ )
36
+
37
+ factor_names = [f["name"].replace("_", " ") for f in active]
38
+ factor_list = ", ".join(factor_names)
39
+
40
+ if level == "LOW":
41
+ return (
42
+ f"Low structural indicators detected for {entity_name}. "
43
+ f"{len(active)} pattern(s) identified: {factor_list}. "
44
+ "Signals are within normal range for publicly available data."
45
+ )
46
+ if level == "MODERATE":
47
+ return (
48
+ f"Moderate structural indicators detected for {entity_name}. "
49
+ f"{len(active)} pattern(s) identified: {factor_list}. "
50
+ "Further review of the associated evidence is warranted. "
51
+ "This is an analytical observation based on public records."
52
+ )
53
+ if level == "HIGH":
54
+ return (
55
+ f"High structural indicators detected for {entity_name}. "
56
+ f"{len(active)} significant pattern(s) identified: {factor_list}. "
57
+ "The combination of these indicators across procurement and audit "
58
+ "data warrants investigative attention. "
59
+ "This is an analytical indicator derived from official public records, "
60
+ "not a legal finding."
61
+ )
62
+ return (
63
+ f"Very high structural indicators detected for {entity_name}. "
64
+ f"{len(active)} strong pattern(s) identified: {factor_list}. "
65
+ "Multiple overlapping patterns across procurement, audit, and disclosure "
66
+ "data are present. All findings are based on official public records. "
67
+ "This is a statistical structural indicator, not a legal determination."
68
+ )
69
+
70
+
71
+ def validate_language(text: str) -> str:
72
+ text_lower = text.lower()
73
+ for word in FORBIDDEN_WORDS:
74
+ if word in text_lower:
75
+ raise ValueError(
76
+ f"Output contains prohibited accusatory term '{word}'. "
77
+ "Revise to use neutral analytical language."
78
+ )
79
+ return text
ai/indicators.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4
+
5
+ from loguru import logger
6
+
7
+
8
+ WEIGHTS = {
9
+ "politician_company_overlap": 0.35,
10
+ "contract_concentration": 0.25,
11
+ "audit_mention_frequency": 0.20,
12
+ "asset_growth_anomaly": 0.15,
13
+ "criminal_case_presence": 0.05,
14
+ }
15
+
16
+ MAX_SCORE = 100
17
+
18
+
19
+ def indicator_politician_company_overlap(entity_id: str, session) -> dict:
20
+ row = session.run(
21
+ """
22
+ MATCH (p {id: $id})-[:DIRECTOR_OF]->(c:Company)-[:WON_CONTRACT]->(ct:Contract)
23
+ RETURN count(ct) AS cnt, sum(ct.amount_crore) AS total
24
+ """,
25
+ id=entity_id
26
+ ).single()
27
+
28
+ cnt = row["cnt"] if row and row["cnt"] else 0
29
+ total = row["total"] if row and row["total"] else 0.0
30
+
31
+ raw = min(int(cnt * 10), 35)
32
+ return {
33
+ "name": "politician_company_overlap",
34
+ "raw_score": raw,
35
+ "weight": WEIGHTS["politician_company_overlap"],
36
+ "weighted": round(raw * WEIGHTS["politician_company_overlap"], 2),
37
+ "description": (
38
+ f"Entity linked to {cnt} contract(s) totalling Rs {total:.1f} Cr "
39
+ "through company directorships."
40
+ ),
41
+ "evidence": [
42
+ f"{cnt} contract(s) found via DIRECTOR_OF -> WON_CONTRACT path",
43
+ f"Total contract value: Rs {round(total, 2)} Cr",
44
+ "Source: Government e-Marketplace procurement records",
45
+ ],
46
+ "source_institution": "Government e-Marketplace",
47
+ "source_url": "https://gem.gov.in",
48
+ }
49
+
50
+
51
+ def indicator_contract_concentration(entity_id: str, session) -> dict:
52
+ row = session.run(
53
+ """
54
+ MATCH (c {id: $id})-[:WON_CONTRACT]->(ct:Contract)
55
+ RETURN count(ct) AS cnt, sum(ct.amount_crore) AS total
56
+ """,
57
+ id=entity_id
58
+ ).single()
59
+
60
+ cnt = row["cnt"] if row and row["cnt"] else 0
61
+ total = row["total"] if row and row["total"] else 0.0
62
+
63
+ raw = min(int(cnt * 8), 25)
64
+ return {
65
+ "name": "contract_concentration",
66
+ "raw_score": raw,
67
+ "weight": WEIGHTS["contract_concentration"],
68
+ "weighted": round(raw * WEIGHTS["contract_concentration"], 2),
69
+ "description": (
70
+ f"Entity awarded {cnt} government contract(s) totalling Rs {total:.1f} Cr. "
71
+ "Repeated awards to the same entity indicate concentration."
72
+ ),
73
+ "evidence": [
74
+ f"{cnt} contract(s) via WON_CONTRACT relationships",
75
+ f"Total value: Rs {round(total, 2)} Cr",
76
+ "Source: Government e-Marketplace procurement records",
77
+ ],
78
+ "source_institution": "Government e-Marketplace",
79
+ "source_url": "https://gem.gov.in",
80
+ }
81
+
82
+
83
+ def indicator_audit_mention_frequency(entity_id: str, entity_name: str,
84
+ session) -> dict:
85
+ row = session.run(
86
+ """
87
+ MATCH (a:AuditReport)
88
+ WHERE toLower(a.title) CONTAINS toLower($name)
89
+ RETURN count(a) AS cnt, sum(a.amount_crore) AS total
90
+ """,
91
+ name=entity_name
92
+ ).single()
93
+
94
+ cnt = row["cnt"] if row and row["cnt"] else 0
95
+ total = row["total"] if row and row["total"] else 0.0
96
+
97
+ raw = min(int(cnt * 10), 20)
98
+ return {
99
+ "name": "audit_mention_frequency",
100
+ "raw_score": raw,
101
+ "weight": WEIGHTS["audit_mention_frequency"],
102
+ "weighted": round(raw * WEIGHTS["audit_mention_frequency"], 2),
103
+ "description": (
104
+ f"Entity or associated names appear in {cnt} CAG audit report(s). "
105
+ f"Total amount flagged in those reports: Rs {total:.1f} Cr."
106
+ ),
107
+ "evidence": [
108
+ f"{cnt} CAG report mention(s)",
109
+ f"Total flagged amount: Rs {round(total, 2)} Cr",
110
+ "Source: Comptroller and Auditor General of India, cag.gov.in",
111
+ ],
112
+ "source_institution": "Comptroller and Auditor General of India",
113
+ "source_url": "https://cag.gov.in/en/audit-report",
114
+ }
115
+
116
+
117
+ def indicator_asset_growth_anomaly(entity_id: str, session) -> dict:
118
+ row = session.run(
119
+ """
120
+ MATCH (p:Politician {id: $id})
121
+ RETURN p.total_assets AS assets
122
+ """,
123
+ id=entity_id
124
+ ).single()
125
+
126
+ assets_str = row["assets"] if row else ""
127
+ raw = 0
128
+ description = "Insufficient asset declaration data for growth analysis."
129
+
130
+ if assets_str and any(c.isdigit() for c in str(assets_str)):
131
+ raw = 5
132
+ description = (
133
+ "Asset declaration data available from election affidavit. "
134
+ "Multi-cycle comparison requires affidavit data from consecutive elections."
135
+ )
136
+
137
+ return {
138
+ "name": "asset_growth_anomaly",
139
+ "raw_score": raw,
140
+ "weight": WEIGHTS["asset_growth_anomaly"],
141
+ "weighted": round(raw * WEIGHTS["asset_growth_anomaly"], 2),
142
+ "description": description,
143
+ "evidence": [
144
+ f"Declared assets: {assets_str or 'not available'}",
145
+ "Source: Election Commission of India candidate affidavit",
146
+ ],
147
+ "source_institution": "Election Commission of India",
148
+ "source_url": "https://myneta.info",
149
+ }
150
+
151
+
152
+ def indicator_criminal_case_presence(entity_id: str, session) -> dict:
153
+ row = session.run(
154
+ """
155
+ MATCH (p:Politician {id: $id})
156
+ RETURN toInteger(p.criminal_cases) AS cases
157
+ """,
158
+ id=entity_id
159
+ ).single()
160
+
161
+ cases = row["cases"] if row and row["cases"] else 0
162
+ raw = min(int(cases * 3), 5)
163
+
164
+ return {
165
+ "name": "criminal_case_presence",
166
+ "raw_score": raw,
167
+ "weight": WEIGHTS["criminal_case_presence"],
168
+ "weighted": round(raw * WEIGHTS["criminal_case_presence"], 2),
169
+ "description": (
170
+ f"Entity has declared {cases} criminal case(s) in their "
171
+ "Election Commission of India candidate affidavit."
172
+ ),
173
+ "evidence": [
174
+ f"{cases} declared criminal case(s)",
175
+ "Source: Election Commission of India candidate affidavit (self-declared)",
176
+ ],
177
+ "source_institution": "Election Commission of India",
178
+ "source_url": "https://eci.gov.in",
179
+ }
180
+
181
+
182
+ ALL_INDICATORS = [
183
+ indicator_politician_company_overlap,
184
+ indicator_contract_concentration,
185
+ indicator_audit_mention_frequency,
186
+ indicator_asset_growth_anomaly,
187
+ indicator_criminal_case_presence,
188
+ ]
ai/risk_scorer.py ADDED
@@ -0,0 +1,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+ import json
4
+ import argparse
5
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
6
+
7
+ from datetime import datetime
8
+ from loguru import logger
9
+
10
+ from ai.indicators import (
11
+ indicator_politician_company_overlap,
12
+ indicator_contract_concentration,
13
+ indicator_audit_mention_frequency,
14
+ indicator_asset_growth_anomaly,
15
+ indicator_criminal_case_presence,
16
+ )
17
+ from ai.explainer import score_to_level, generate_explanation, validate_language
18
+
19
+
20
+ class RiskScorer:
21
+
22
+ def __init__(self):
23
+ self.driver = None
24
+ self._connect()
25
+
26
+ def _connect(self):
27
+ try:
28
+ from neo4j import GraphDatabase
29
+ from dotenv import load_dotenv
30
+ load_dotenv()
31
+ uri = os.getenv("NEO4J_URI", "")
32
+ user = os.getenv("NEO4J_USER", "neo4j")
33
+ pwd = os.getenv("NEO4J_PASSWORD", "")
34
+ if not uri or not pwd:
35
+ raise ValueError("NEO4J_URI and NEO4J_PASSWORD must be set in .env")
36
+ self.driver = GraphDatabase.driver(uri, auth=(user, pwd))
37
+ self.driver.verify_connectivity()
38
+ logger.success("[RiskScorer] Connected to Neo4j")
39
+ except Exception as e:
40
+ logger.error(f"[RiskScorer] Connection failed: {e}")
41
+ raise
42
+
43
+ def score_entity(self, entity_id: str) -> dict:
44
+ with self.driver.session() as session:
45
+ entity = session.run(
46
+ "MATCH (n {id: $id}) RETURN n.name AS name, labels(n)[0] AS type",
47
+ id=entity_id
48
+ ).single()
49
+
50
+ if not entity:
51
+ return {
52
+ "entity_id": entity_id,
53
+ "entity_name": entity_id,
54
+ "entity_type": "unknown",
55
+ "risk_score": 0,
56
+ "risk_level": "UNKNOWN",
57
+ "factors": [],
58
+ "explanation": "Entity not found in graph.",
59
+ "scored_at": datetime.now().isoformat(),
60
+ }
61
+
62
+ entity_name = entity["name"] or entity_id
63
+ entity_type = entity["type"] or "unknown"
64
+
65
+ factors = [
66
+ indicator_politician_company_overlap(entity_id, session),
67
+ indicator_contract_concentration(entity_id, session),
68
+ indicator_audit_mention_frequency(entity_id, entity_name, session),
69
+ indicator_asset_growth_anomaly(entity_id, session),
70
+ indicator_criminal_case_presence(entity_id, session),
71
+ ]
72
+
73
+ total = sum(f["raw_score"] for f in factors)
74
+ score = min(total, 100)
75
+ level = score_to_level(score)
76
+ explanation = validate_language(
77
+ generate_explanation(entity_name, score, factors)
78
+ )
79
+
80
+ logger.info(
81
+ f"[RiskScorer] {entity_name}: score={score} level={level} "
82
+ f"factors={len([f for f in factors if f['raw_score'] > 0])}"
83
+ )
84
+
85
+ return {
86
+ "entity_id": entity_id,
87
+ "entity_name": entity_name,
88
+ "entity_type": entity_type,
89
+ "risk_score": score,
90
+ "risk_level": level,
91
+ "factors": factors,
92
+ "explanation": explanation,
93
+ "sources": list({
94
+ f["source_institution"]: f["source_url"]
95
+ for f in factors
96
+ }.items()),
97
+ "scored_at": datetime.now().isoformat(),
98
+ }
99
+
100
+ def score_all_politicians(self, limit: int = 50) -> list:
101
+ with self.driver.session() as session:
102
+ rows = session.run(
103
+ "MATCH (p:Politician) RETURN p.id AS id LIMIT $limit",
104
+ limit=limit
105
+ ).data()
106
+
107
+ results = []
108
+ for row in rows:
109
+ try:
110
+ result = self.score_entity(row["id"])
111
+ results.append(result)
112
+ except Exception as e:
113
+ logger.error(f"[RiskScorer] Failed to score {row['id']}: {e}")
114
+
115
+ results.sort(key=lambda x: x["risk_score"], reverse=True)
116
+ logger.success(f"[RiskScorer] Scored {len(results)} politicians")
117
+ return results
118
+
119
+ def score_all_companies(self, limit: int = 50) -> list:
120
+ with self.driver.session() as session:
121
+ rows = session.run(
122
+ "MATCH (c:Company) RETURN c.id AS id LIMIT $limit",
123
+ limit=limit
124
+ ).data()
125
+
126
+ results = []
127
+ for row in rows:
128
+ try:
129
+ result = self.score_entity(row["id"])
130
+ results.append(result)
131
+ except Exception as e:
132
+ logger.error(f"[RiskScorer] Failed to score {row['id']}: {e}")
133
+
134
+ results.sort(key=lambda x: x["risk_score"], reverse=True)
135
+ logger.success(f"[RiskScorer] Scored {len(results)} companies")
136
+ return results
137
+
138
+ def save_scores(self, scores: list, filepath: str):
139
+ os.makedirs(os.path.dirname(filepath), exist_ok=True)
140
+ with open(filepath, "w", encoding="utf-8") as f:
141
+ json.dump(scores, f, indent=2, ensure_ascii=False)
142
+ logger.success(f"[RiskScorer] Saved {len(scores)} scores to {filepath}")
143
+
144
+ def write_scores_to_graph(self, scores: list):
145
+ with self.driver.session() as session:
146
+ for s in scores:
147
+ session.run(
148
+ """
149
+ MATCH (n {id: $id})
150
+ SET n.risk_score = $score,
151
+ n.risk_level = $level,
152
+ n.scored_at = $at
153
+ """,
154
+ id=s["entity_id"],
155
+ score=s["risk_score"],
156
+ level=s["risk_level"],
157
+ at=s["scored_at"],
158
+ )
159
+ logger.success(f"[RiskScorer] Wrote {len(scores)} scores to Neo4j nodes")
160
+
161
+ def close(self):
162
+ if self.driver:
163
+ self.driver.close()
164
+
165
+
166
+ if __name__ == "__main__":
167
+ parser = argparse.ArgumentParser(description="BharatGraph Risk Scorer")
168
+ parser.add_argument("--entity-id", type=str, default=None)
169
+ parser.add_argument("--all", action="store_true")
170
+ parser.add_argument("--write-graph", action="store_true")
171
+ args = parser.parse_args()
172
+
173
+ print("=" * 55)
174
+ print("BharatGraph - Risk Scoring Engine")
175
+ print("=" * 55)
176
+
177
+ scorer = RiskScorer()
178
+
179
+ if args.entity_id:
180
+ result = scorer.score_entity(args.entity_id)
181
+ print(f"\nEntity: {result['entity_name']}")
182
+ print(f"Type: {result['entity_type']}")
183
+ print(f"Risk Score: {result['risk_score']} / 100")
184
+ print(f"Risk Level: {result['risk_level']}")
185
+ print(f"\nExplanation:\n {result['explanation']}")
186
+ print(f"\nFactors:")
187
+ for f in result["factors"]:
188
+ if f["raw_score"] > 0:
189
+ print(f" {f['name']}: {f['raw_score']} pts")
190
+ print(f" {f['description']}")
191
+
192
+ elif args.all:
193
+ politicians = scorer.score_all_politicians(limit=100)
194
+ companies = scorer.score_all_companies(limit=100)
195
+ all_scores = politicians + companies
196
+
197
+ if args.write_graph:
198
+ scorer.write_scores_to_graph(all_scores)
199
+
200
+ from datetime import datetime as dt
201
+ ts = dt.now().strftime("%Y%m%d_%H%M%S")
202
+ scorer.save_scores(all_scores, f"data/processed/risk_scores_{ts}.json")
203
+
204
+ print(f"\nTotal scored: {len(all_scores)}")
205
+ print(f"High or above: {len([s for s in all_scores if s['risk_score'] >= 61])}")
206
+ print(f"With any indicator: {len([s for s in all_scores if s['risk_score'] > 0])}")
207
+
208
+ if all_scores:
209
+ print(f"\nTop results:")
210
+ for s in all_scores[:5]:
211
+ print(f" {s['entity_name']:30s} {s['risk_score']:3d} {s['risk_level']}")
212
+ else:
213
+ print("\nUsage:")
214
+ print(" python -m ai.risk_scorer --entity-id <id>")
215
+ print(" python -m ai.risk_scorer --all")
216
+ print(" python -m ai.risk_scorer --all --write-graph")
217
+
218
+ scorer.close()