Spaces:
Running
Running
feat(phase-34): wire BenfordsAnalyzer + GhostCompanyDetector + ShadowDirectorDetector into risk scorer
4ac80cb | import os, sys | |
| sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) | |
| from datetime import datetime | |
| from fastapi import APIRouter, Depends, HTTPException | |
| from loguru import logger | |
| from api.models import RiskResponse, RiskFactor, SourceDocument | |
| from api.dependencies import get_db | |
| from ai.benfords_analyzer import BenfordsAnalyzer | |
| from ai.ghost_company import GhostCompanyDetector | |
| from ai.shadow_director import ShadowDirectorDetector | |
| from ai.explainer import generate_explanation | |
| router = APIRouter() | |
| RISK_LEVELS = { | |
| (0, 0): "NONE", # M-06 FIX: explicit zero-score state (no factors found) | |
| (1, 30): "LOW", | |
| (31, 60): "MODERATE", | |
| (61, 80): "HIGH", | |
| (81, 100): "VERY_HIGH", | |
| } | |
| def score_to_level(score: int) -> str: | |
| # M-08 FIX: clamp to 0-100 before lookup -- scores >100 returned "UNKNOWN" | |
| clamped = max(0, min(int(score), 100)) | |
| for (lo, hi), level in RISK_LEVELS.items(): | |
| if lo <= clamped <= hi: | |
| return level | |
| return "UNKNOWN" | |
| def get_risk(entity_id: str, driver=Depends(get_db)): | |
| logger.info(f"[Risk] entity_id={entity_id}") | |
| with driver.session() as session: | |
| entity = session.run( | |
| """ | |
| MATCH (n {id: $id}) | |
| RETURN n.name AS name, labels(n)[0] AS type | |
| """, | |
| id=entity_id | |
| ).single() | |
| if not entity: | |
| raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found") | |
| entity_name = entity["name"] or entity_id | |
| factors = [] | |
| total_score = 0 | |
| contract_rows = session.run( | |
| """ | |
| MATCH (p {id: $id})-[:DIRECTOR_OF]->(c:Company)-[:WON_CONTRACT]->(ct:Contract) | |
| RETURN count(ct) AS contract_count, sum(ct.amount_crore) AS total_crore | |
| """, | |
| id=entity_id | |
| ).single() | |
| contract_count = contract_rows["contract_count"] if contract_rows else 0 | |
| # C-02 FIX: SUM() returns None (Python None) when no rows match. | |
| # None cannot be formatted with :.1f -- guard with "or 0" | |
| total_crore = (contract_rows["total_crore"] if contract_rows else 0) or 0 | |
| if contract_count and contract_count > 0: | |
| raw = min(contract_count * 10, 35) | |
| factors.append(RiskFactor( | |
| name="politician_company_contract_overlap", | |
| score=raw, | |
| weight=0.35, | |
| description=( | |
| f"Entity is linked to {contract_count} government contract(s) " | |
| f"totalling Rs {total_crore:.1f} Cr through company directorships." | |
| ), | |
| evidence=[ | |
| f"{contract_count} contract(s) via DIRECTOR_OF -> WON_CONTRACT path", | |
| f"Total contract value: Rs {total_crore:.2f} Cr", | |
| ], | |
| )) | |
| total_score += raw | |
| repeat_rows = session.run( | |
| """ | |
| MATCH (co:Company {id: $id})-[:WON_CONTRACT]->(ct:Contract) | |
| WITH count(ct) AS cnt | |
| WHERE cnt >= 2 | |
| RETURN cnt | |
| """, | |
| id=entity_id | |
| ).single() | |
| if repeat_rows: | |
| raw = min(repeat_rows["cnt"] * 8, 25) | |
| factors.append(RiskFactor( | |
| name="contract_concentration", | |
| score=raw, | |
| weight=0.25, | |
| description=( | |
| f"Company won {repeat_rows['cnt']} contracts from government portals. " | |
| "Repeated contract awards to the same entity indicate concentration." | |
| ), | |
| evidence=[ | |
| f"{repeat_rows['cnt']} contracts found via WON_CONTRACT relationships", | |
| "Source: Government e-Marketplace procurement records", | |
| ], | |
| )) | |
| total_score += raw | |
| # H-08 FIX: name substring match causes massive false positives | |
| # (e.g. "India" matches every CAG report). Use relationship-based | |
| # matching first; fall back to exact-word name match only. | |
| audit_rows = session.run( | |
| """ | |
| OPTIONAL MATCH (n {id: $id})-[:MENTIONED_IN]->(a1:AuditReport) | |
| WITH collect(a1) AS direct | |
| OPTIONAL MATCH (a2:AuditReport) | |
| WHERE size(direct) = 0 | |
| AND (toLower(a2.title) CONTAINS (' ' + toLower($name) + ' ') | |
| OR toLower(a2.title) STARTS WITH (toLower($name) + ' ') | |
| OR toLower(a2.title) ENDS WITH (' ' + toLower($name))) | |
| RETURN count(direct) + count(a2) AS audit_count | |
| """, | |
| id=entity_id, name=entity_name | |
| ).single() | |
| audit_count = audit_rows["audit_count"] if audit_rows else 0 | |
| if audit_count and audit_count > 0: | |
| raw = min(audit_count * 10, 20) | |
| factors.append(RiskFactor( | |
| name="audit_mention_frequency", | |
| score=raw, | |
| weight=0.20, | |
| description=( | |
| f"Entity or associated names appear in {audit_count} CAG audit report(s)." | |
| ), | |
| evidence=[ | |
| f"{audit_count} CAG report mention(s)", | |
| "Source: Comptroller and Auditor General reports, cag.gov.in", | |
| ], | |
| )) | |
| total_score += raw | |
| criminal_rows = session.run( | |
| """ | |
| MATCH (p:Politician {id: $id}) | |
| WHERE toInteger(p.criminal_cases) > 0 | |
| RETURN toInteger(p.criminal_cases) AS cases | |
| """, | |
| id=entity_id | |
| ).single() | |
| if criminal_rows: | |
| raw = min(criminal_rows["cases"] * 3, 5) | |
| factors.append(RiskFactor( | |
| name="declared_criminal_cases", | |
| score=raw, | |
| weight=0.05, | |
| description=( | |
| f"Entity has declared {criminal_rows['cases']} criminal case(s) " | |
| "in their Election Commission affidavit." | |
| ), | |
| evidence=[ | |
| f"{criminal_rows['cases']} declared criminal case(s)", | |
| "Source: Election Commission of India candidate affidavit", | |
| ], | |
| )) | |
| total_score += raw | |
| # Phase 34: Benford Law analysis on affidavit asset values | |
| try: | |
| ba = BenfordsAnalyzer() | |
| asset_rows = session.run( | |
| "MATCH (p {id:})-[:FILED_AFFIDAVIT]->(a:Affidavit)" | |
| " RETURN a.total_assets_crore AS v", | |
| id=entity_id | |
| ).data() | |
| asset_vals = [r["v"] for r in asset_rows if r.get("v")] | |
| if len(asset_vals) >= 5: | |
| bf = ba.analyze(asset_vals) | |
| chi2 = bf.get("chi2_statistic", 0) or 0 | |
| if chi2 > 15.5: # p<0.05 threshold | |
| raw = min(int(chi2 / 2), 20) | |
| factors.append(RiskFactor( | |
| name="benfords_law_anomaly", | |
| score=raw, | |
| weight=0.20, | |
| description=( | |
| f"Asset declarations deviate significantly from " | |
| f"Benford Law distribution (chi2={chi2:.1f}). " | |
| "Fabricated or rounded figures can cause this pattern." | |
| ), | |
| evidence=[ | |
| f"Chi-squared statistic: {chi2:.2f} (threshold 15.5)", | |
| f"Analysed {len(asset_vals)} affidavit asset values", | |
| "Source: Election Commission affidavit data", | |
| ], | |
| )) | |
| total_score += raw | |
| except Exception as _bf_e: | |
| logger.debug(f"[Risk] Benford analysis skipped: {type(_bf_e).__name__}") | |
| # Phase 34: ghost company detection | |
| try: | |
| co_rows = session.run( | |
| "MATCH (co:Company)-[:DIRECTOR_OF|:LINKED_TO*1..2]-(n {id:})" | |
| " RETURN co.id AS id, co.name AS name," | |
| " co.employee_count AS emp," | |
| " co.registered_capital_crore AS cap" | |
| " LIMIT 20", | |
| id=entity_id | |
| ).data() | |
| if co_rows: | |
| gcd = GhostCompanyDetector(driver=driver) | |
| scored = [gcd.score_company(r) for r in co_rows] | |
| ghosts = [s for s in scored if s.get("ghost_score", 0) >= 70] | |
| if ghosts: | |
| raw = min(len(ghosts) * 12, 24) | |
| factors.append(RiskFactor( | |
| name="ghost_company_association", | |
| score=raw, | |
| weight=0.24, | |
| description=( | |
| f"Entity is linked to {len(ghosts)} company/companies " | |
| "showing ghost company indicators (no employees, " | |
| "minimal capital, high contract volume)." | |
| ), | |
| evidence=[ | |
| f"{len(ghosts)} ghost company indicator(s) detected", | |
| ", ".join(g.get("name","") for g in ghosts[:3]), | |
| "Source: MCA filings + GeM procurement records", | |
| ], | |
| )) | |
| total_score += raw | |
| except Exception as _gc_e: | |
| logger.debug(f"[Risk] Ghost company check skipped: {type(_gc_e).__name__}") | |
| # Phase 34: shadow director detection | |
| try: | |
| dir_rows = session.run( | |
| "MATCH (n {id:})-[:DIRECTOR_OF]->(co:Company)" | |
| " RETURN count(co) AS dir_count", | |
| id=entity_id | |
| ).single() | |
| dir_count = dir_rows["dir_count"] if dir_rows else 0 | |
| if dir_count >= 10: | |
| raw = min(dir_count * 2, 15) | |
| factors.append(RiskFactor( | |
| name="high_directorship_count", | |
| score=raw, | |
| weight=0.15, | |
| description=( | |
| f"Entity is director of {dir_count} companies. " | |
| "High directorship counts are a shadow director indicator." | |
| ), | |
| evidence=[ | |
| f"{dir_count} DIRECTOR_OF relationships in graph", | |
| "Source: MCA company filings", | |
| ], | |
| )) | |
| total_score += raw | |
| except Exception as _sd_e: | |
| logger.debug(f"[Risk] Shadow director check skipped: {type(_sd_e).__name__}") | |
| final_score = max(0, min(total_score, 100)) # M-08 FIX: clamp both directions | |
| level = score_to_level(final_score) | |
| if final_score == 0: | |
| explanation = ( | |
| f"No structural risk indicators found for {entity_name} in the current " | |
| "dataset. This may reflect limited data coverage rather than the absence " | |
| "of patterns." | |
| ) | |
| elif final_score <= 30: | |
| explanation = ( | |
| f"Low structural indicators detected for {entity_name}. " | |
| f"{len(factors)} factor(s) identified with minor pattern signals." | |
| ) | |
| elif final_score <= 60: | |
| explanation = ( | |
| f"Moderate structural indicators detected for {entity_name}. " | |
| f"{len(factors)} factor(s) identified. Further investigation warranted." | |
| ) | |
| else: | |
| explanation = ( | |
| f"High structural indicators detected for {entity_name}. " | |
| f"{len(factors)} significant pattern(s) identified across procurement " | |
| "and audit data. This is an analytical indicator, not a legal finding." | |
| ) | |
| return RiskResponse( | |
| entity_id=entity_id, | |
| entity_name=entity_name, | |
| risk_score=final_score, | |
| risk_level=level, | |
| factors=factors, | |
| explanation=explanation, | |
| sources=[ | |
| SourceDocument( | |
| institution="Government e-Marketplace", | |
| document_title="GeM Procurement Records", | |
| url="https://gem.gov.in", | |
| ), | |
| SourceDocument( | |
| institution="Comptroller and Auditor General", | |
| document_title="CAG Audit Reports", | |
| url="https://cag.gov.in", | |
| ), | |
| ], | |
| generated_at=datetime.now().isoformat(), | |
| ) | |