Spaces:
Build error
feat(ai): complete — Adversarial Counterevidence and Competing Hypotheses
Browse filesai/adversarial_engine.py: forced counterevidence engine
For every HIGH-severity finding, the engine generates a primary
hypothesis (finding as stated) and a contra hypothesis (alternative
innocent explanation), then evaluates evidence for each.
Contra templates: 8 finding types with 2 contra explanations each
(contract_concentration, ghost_company, affidavit_wealth,
granger_causality, bid_document_similarity, cooling_off_violation,
benami, default fallback)
Confidence scoring: contra confidence starts at 0.15 for HIGH
findings, 0.30 for MODERATE, 0.45 for LOW. Database
counterevidence raises it by 0.15 per piece found.
Verdicts: PRIMARY_HYPOTHESIS_SUPPORTED when primary/contra ratio
exceeds 3.0. INCONCLUSIVE when ratio is between 0.8 and 1.5.
CONTRA_HYPOTHESIS_PROBABLE below 0.8.
Overall assessment: FINDINGS_SUPPORTED, FINDINGS_WEAKENED,
or INCONCLUSIVE across all hypotheses in the report.
api/routes/adversarial.py: GET /adversarial/{entity_id}
Pulls findings from risk scorer, runs adversarial analysis,
returns hypothesis pairs with confidence and verdict per finding.
api/main.py: adversarial router registered, version 0.26.0
- ai/adversarial_engine.py +310 -0
- api/main.py +4 -3
- api/routes/adversarial.py +41 -0
|
@@ -0,0 +1,310 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os, sys, math, re
|
| 2 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 3 |
+
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from loguru import logger
|
| 6 |
+
|
| 7 |
+
CONFIDENCE_THRESHOLD = 0.6 # minimum evidence score to keep a hypothesis
|
| 8 |
+
MIN_FINDINGS_FOR_DEBATE = 2
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AdversarialEngine:
|
| 12 |
+
"""
|
| 13 |
+
Forced counterevidence engine with competing hypotheses mode.
|
| 14 |
+
|
| 15 |
+
For every HIGH-severity finding, the engine:
|
| 16 |
+
1. Generates an alternative (contra) hypothesis that explains
|
| 17 |
+
the same data without implying wrongdoing.
|
| 18 |
+
2. Searches for evidence that supports the contra hypothesis.
|
| 19 |
+
3. Produces a scorecard with evidence-for and evidence-against
|
| 20 |
+
each hypothesis.
|
| 21 |
+
4. Adjusts finding confidence based on counterevidence strength.
|
| 22 |
+
|
| 23 |
+
This prevents confirmation bias in the investigation pipeline
|
| 24 |
+
and produces legally defensible, balanced outputs.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
CONTRA_TEMPLATES = {
|
| 28 |
+
"contract_concentration": [
|
| 29 |
+
"Entity operates in a specialised sector where few vendors qualify, "
|
| 30 |
+
"making repeated awards to the same company structurally expected.",
|
| 31 |
+
"Contracts were awarded through open competitive tender. Repeated wins "
|
| 32 |
+
"reflect competitive pricing, not preferential treatment.",
|
| 33 |
+
],
|
| 34 |
+
"ghost_company": [
|
| 35 |
+
"Company was incorporated shortly before the contract because the "
|
| 36 |
+
"contract opportunity itself prompted the business formation.",
|
| 37 |
+
"Registration date proximity to contract may reflect data lag in "
|
| 38 |
+
"official records rather than actual operational timeline.",
|
| 39 |
+
],
|
| 40 |
+
"affidavit_wealth": [
|
| 41 |
+
"Asset growth reflects legitimate investment returns, property "
|
| 42 |
+
"appreciation, and inheritance not captured in salary declarations.",
|
| 43 |
+
"Declared assets in earlier years may have been understated due "
|
| 44 |
+
"to lack of independent valuation, not deliberate underreporting.",
|
| 45 |
+
],
|
| 46 |
+
"granger_causality": [
|
| 47 |
+
"Statistical correlation between policy events and contract awards "
|
| 48 |
+
"reflects normal government procurement cycles, not causal influence.",
|
| 49 |
+
"Both policy changes and contract awards are driven by the same "
|
| 50 |
+
"underlying budget allocations, producing apparent but spurious correlation.",
|
| 51 |
+
],
|
| 52 |
+
"bid_document_similarity": [
|
| 53 |
+
"Bid documents use standard government procurement templates, making "
|
| 54 |
+
"structural similarity across vendors expected and unrelated to coordination.",
|
| 55 |
+
"Similarity reflects industry-standard specifications published by the "
|
| 56 |
+
"procuring ministry, not copying between vendors.",
|
| 57 |
+
],
|
| 58 |
+
"cooling_off_violation": [
|
| 59 |
+
"The appointment was to a non-regulated sector not covered by "
|
| 60 |
+
"the cooling-off policy applicable to this official's role.",
|
| 61 |
+
"The private sector role is advisory in nature and does not involve "
|
| 62 |
+
"the same subject matter as the official's prior government function.",
|
| 63 |
+
],
|
| 64 |
+
"benami": [
|
| 65 |
+
"Director relationship reflects a legitimate family business structure "
|
| 66 |
+
"where multiple family members hold formal positions.",
|
| 67 |
+
"Address sharing reflects co-location in a business park or registered "
|
| 68 |
+
"office service, common among small and medium enterprises.",
|
| 69 |
+
],
|
| 70 |
+
"default": [
|
| 71 |
+
"The observed pattern may reflect coincidence, data quality issues, "
|
| 72 |
+
"or structural features of the sector rather than intentional conduct.",
|
| 73 |
+
"Additional context from primary source documents is required before "
|
| 74 |
+
"drawing conclusions from this structural indicator.",
|
| 75 |
+
],
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
def analyze(self, entity_id: str, entity_name: str,
|
| 79 |
+
findings: list[dict], driver=None) -> dict:
|
| 80 |
+
logger.info(
|
| 81 |
+
f"[AdversarialEngine] Analyzing {len(findings)} findings "
|
| 82 |
+
f"for {entity_name}"
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
if not findings:
|
| 86 |
+
return {
|
| 87 |
+
"entity_id": entity_id,
|
| 88 |
+
"entity_name": entity_name,
|
| 89 |
+
"status": "no_findings",
|
| 90 |
+
"hypotheses": [],
|
| 91 |
+
"analyzed_at": datetime.now().isoformat(),
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
high_findings = [f for f in findings
|
| 95 |
+
if f.get("severity") in ("HIGH", "VERY_HIGH")]
|
| 96 |
+
if not high_findings:
|
| 97 |
+
high_findings = findings[:3]
|
| 98 |
+
|
| 99 |
+
hypotheses = []
|
| 100 |
+
for finding in high_findings:
|
| 101 |
+
h = self._build_hypothesis_pair(finding, entity_name, driver)
|
| 102 |
+
hypotheses.append(h)
|
| 103 |
+
|
| 104 |
+
overall = self._overall_assessment(hypotheses)
|
| 105 |
+
|
| 106 |
+
logger.success(
|
| 107 |
+
f"[AdversarialEngine] {entity_name}: "
|
| 108 |
+
f"{len(hypotheses)} hypothesis pairs, "
|
| 109 |
+
f"overall={overall['verdict']}"
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
return {
|
| 113 |
+
"entity_id": entity_id,
|
| 114 |
+
"entity_name": entity_name,
|
| 115 |
+
"hypotheses": hypotheses,
|
| 116 |
+
"overall": overall,
|
| 117 |
+
"methodology": (
|
| 118 |
+
"For each HIGH finding, a primary hypothesis (finding as stated) "
|
| 119 |
+
"and a contra hypothesis (alternative innocent explanation) are "
|
| 120 |
+
"evaluated against available evidence. Confidence is adjusted "
|
| 121 |
+
"based on the strength of counterevidence."
|
| 122 |
+
),
|
| 123 |
+
"analyzed_at": datetime.now().isoformat(),
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
def _build_hypothesis_pair(self, finding: dict,
|
| 127 |
+
entity_name: str,
|
| 128 |
+
driver) -> dict:
|
| 129 |
+
finding_type = finding.get("type", "default")
|
| 130 |
+
|
| 131 |
+
primary = {
|
| 132 |
+
"label": "Primary Hypothesis",
|
| 133 |
+
"description": finding.get("description",""),
|
| 134 |
+
"type": finding_type,
|
| 135 |
+
"evidence_for": finding.get("evidence", []),
|
| 136 |
+
"evidence_against": [],
|
| 137 |
+
"confidence": 1.0,
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
contra_texts = self.CONTRA_TEMPLATES.get(
|
| 141 |
+
finding_type,
|
| 142 |
+
self.CONTRA_TEMPLATES["default"]
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
db_evidence = self._search_counterevidence(
|
| 146 |
+
finding_type, entity_name, driver
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
contra = {
|
| 150 |
+
"label": "Contra Hypothesis",
|
| 151 |
+
"description": contra_texts[0],
|
| 152 |
+
"type": f"contra_{finding_type}",
|
| 153 |
+
"evidence_for": [contra_texts[1]] + db_evidence,
|
| 154 |
+
"evidence_against": finding.get("evidence", []),
|
| 155 |
+
"confidence": self._score_contra(db_evidence, finding),
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
primary["confidence"] = max(
|
| 159 |
+
0.1, 1.0 - contra["confidence"] * 0.4
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
verdict = self._verdict(primary["confidence"], contra["confidence"])
|
| 163 |
+
|
| 164 |
+
return {
|
| 165 |
+
"finding_type": finding_type,
|
| 166 |
+
"severity": finding.get("severity",""),
|
| 167 |
+
"primary": primary,
|
| 168 |
+
"contra": contra,
|
| 169 |
+
"verdict": verdict,
|
| 170 |
+
"note": (
|
| 171 |
+
"This scorecard presents both the primary structural indicator "
|
| 172 |
+
"and an alternative explanation. Neither constitutes a legal "
|
| 173 |
+
"finding. Further primary source investigation is required."
|
| 174 |
+
),
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
def _search_counterevidence(self, finding_type: str,
|
| 178 |
+
entity_name: str,
|
| 179 |
+
driver) -> list[str]:
|
| 180 |
+
if not driver:
|
| 181 |
+
return []
|
| 182 |
+
evidence = []
|
| 183 |
+
try:
|
| 184 |
+
with driver.session() as s:
|
| 185 |
+
if finding_type == "contract_concentration":
|
| 186 |
+
row = s.run(
|
| 187 |
+
"""
|
| 188 |
+
MATCH (c:Company)-[:WON_CONTRACT]->(ct:Contract)
|
| 189 |
+
WHERE toLower(c.name) CONTAINS toLower($name)
|
| 190 |
+
WITH ct.buyer_org AS buyer, count(*) AS n
|
| 191 |
+
WHERE n >= 2
|
| 192 |
+
RETURN count(*) AS repeat_buyers
|
| 193 |
+
""", name=entity_name
|
| 194 |
+
).single()
|
| 195 |
+
if row and row.get("repeat_buyers",0) == 0:
|
| 196 |
+
evidence.append(
|
| 197 |
+
"No repeat buyers found — contracts came from "
|
| 198 |
+
"different ministries, suggesting open competition."
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
elif finding_type in ("granger_causality", "transfer_entropy"):
|
| 202 |
+
row = s.run(
|
| 203 |
+
"""
|
| 204 |
+
MATCH (p {id:$name})-[:MEMBER_OF]->(party:Party)
|
| 205 |
+
RETURN party.name AS party
|
| 206 |
+
""", name=entity_name
|
| 207 |
+
).single()
|
| 208 |
+
if row:
|
| 209 |
+
evidence.append(
|
| 210 |
+
f"Entity is a member of {row['party']} — budget "
|
| 211 |
+
"allocations are party-level, not individual decisions."
|
| 212 |
+
)
|
| 213 |
+
except Exception as e:
|
| 214 |
+
logger.warning(f"[Adversarial] Counterevidence search failed: {e}")
|
| 215 |
+
return evidence
|
| 216 |
+
|
| 217 |
+
def _score_contra(self, db_evidence: list[str],
|
| 218 |
+
finding: dict) -> float:
|
| 219 |
+
base = 0.25
|
| 220 |
+
boost = min(0.4, len(db_evidence) * 0.15)
|
| 221 |
+
sev = finding.get("severity","")
|
| 222 |
+
if sev == "LOW":
|
| 223 |
+
base = 0.45
|
| 224 |
+
elif sev == "MODERATE":
|
| 225 |
+
base = 0.30
|
| 226 |
+
elif sev in ("HIGH", "VERY_HIGH"):
|
| 227 |
+
base = 0.15
|
| 228 |
+
return round(min(0.90, base + boost), 3)
|
| 229 |
+
|
| 230 |
+
def _verdict(self, primary_conf: float, contra_conf: float) -> str:
|
| 231 |
+
ratio = primary_conf / max(contra_conf, 0.01)
|
| 232 |
+
if ratio > 3.0:
|
| 233 |
+
return "PRIMARY_HYPOTHESIS_SUPPORTED"
|
| 234 |
+
elif ratio > 1.5:
|
| 235 |
+
return "PRIMARY_HYPOTHESIS_PROBABLE"
|
| 236 |
+
elif ratio > 0.8:
|
| 237 |
+
return "INCONCLUSIVE"
|
| 238 |
+
else:
|
| 239 |
+
return "CONTRA_HYPOTHESIS_PROBABLE"
|
| 240 |
+
|
| 241 |
+
def _overall_assessment(self, hypotheses: list[dict]) -> dict:
|
| 242 |
+
if not hypotheses:
|
| 243 |
+
return {"verdict": "NO_FINDINGS", "summary": "No findings to assess."}
|
| 244 |
+
|
| 245 |
+
verdicts = [h["verdict"] for h in hypotheses]
|
| 246 |
+
supported = sum(1 for v in verdicts
|
| 247 |
+
if v in ("PRIMARY_HYPOTHESIS_SUPPORTED",
|
| 248 |
+
"PRIMARY_HYPOTHESIS_PROBABLE"))
|
| 249 |
+
inconclusive = sum(1 for v in verdicts if v == "INCONCLUSIVE")
|
| 250 |
+
contra_wins = sum(1 for v in verdicts if v == "CONTRA_HYPOTHESIS_PROBABLE")
|
| 251 |
+
|
| 252 |
+
if supported > inconclusive + contra_wins:
|
| 253 |
+
verdict = "FINDINGS_SUPPORTED"
|
| 254 |
+
summary = (
|
| 255 |
+
f"{supported} of {len(hypotheses)} findings remain supported "
|
| 256 |
+
f"after counterevidence analysis."
|
| 257 |
+
)
|
| 258 |
+
elif contra_wins > supported:
|
| 259 |
+
verdict = "FINDINGS_WEAKENED"
|
| 260 |
+
summary = (
|
| 261 |
+
f"Counterevidence analysis weakens {contra_wins} of "
|
| 262 |
+
f"{len(hypotheses)} findings. Additional investigation recommended."
|
| 263 |
+
)
|
| 264 |
+
else:
|
| 265 |
+
verdict = "INCONCLUSIVE"
|
| 266 |
+
summary = (
|
| 267 |
+
f"Mixed results across {len(hypotheses)} findings. "
|
| 268 |
+
f"Neither primary nor contra hypotheses are clearly dominant."
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
return {
|
| 272 |
+
"verdict": verdict,
|
| 273 |
+
"summary": summary,
|
| 274 |
+
"supported": supported,
|
| 275 |
+
"inconclusive": inconclusive,
|
| 276 |
+
"contra_wins": contra_wins,
|
| 277 |
+
"total_assessed": len(hypotheses),
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
if __name__ == "__main__":
|
| 282 |
+
print("=" * 55)
|
| 283 |
+
print("BharatGraph — Adversarial Engine Test")
|
| 284 |
+
print("=" * 55)
|
| 285 |
+
engine = AdversarialEngine()
|
| 286 |
+
sample_findings = [
|
| 287 |
+
{
|
| 288 |
+
"type": "contract_concentration",
|
| 289 |
+
"severity": "HIGH",
|
| 290 |
+
"description": "Three contracts from the same ministry in 18 months.",
|
| 291 |
+
"evidence": ["Contract CT001: Rs 12 Cr", "Contract CT002: Rs 18 Cr",
|
| 292 |
+
"Contract CT003: Rs 9 Cr"],
|
| 293 |
+
},
|
| 294 |
+
{
|
| 295 |
+
"type": "granger_causality",
|
| 296 |
+
"severity": "HIGH",
|
| 297 |
+
"description": "Policy events predict contract awards (F=3.2).",
|
| 298 |
+
"evidence": ["F-statistic: 3.2", "Lag: 2"],
|
| 299 |
+
},
|
| 300 |
+
]
|
| 301 |
+
r = engine.analyze("pol_001", "Test Entity", sample_findings, driver=None)
|
| 302 |
+
print(f"\n Hypotheses: {len(r['hypotheses'])}")
|
| 303 |
+
print(f" Overall: {r['overall']['verdict']}")
|
| 304 |
+
print(f" Summary: {r['overall']['summary']}")
|
| 305 |
+
for h in r["hypotheses"]:
|
| 306 |
+
print(f"\n [{h['finding_type']}]")
|
| 307 |
+
print(f" Primary confidence: {h['primary']['confidence']:.2f}")
|
| 308 |
+
print(f" Contra confidence: {h['contra']['confidence']:.2f}")
|
| 309 |
+
print(f" Verdict: {h['verdict']}")
|
| 310 |
+
print("\nDone!")
|
|
@@ -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, conflict, linguistic, policy
|
| 14 |
from api.models import HealthResponse, StatsResponse
|
| 15 |
|
| 16 |
|
|
@@ -34,7 +34,7 @@ app = FastAPI(
|
|
| 34 |
"All data sourced from official government records. "
|
| 35 |
"Outputs are structural indicators, not legal findings."
|
| 36 |
),
|
| 37 |
-
version="0.
|
| 38 |
lifespan=lifespan,
|
| 39 |
)
|
| 40 |
|
|
@@ -72,6 +72,7 @@ app.include_router(procurement.router, tags=["Procurement"])
|
|
| 72 |
app.include_router(conflict.router, tags=["Conflict"])
|
| 73 |
app.include_router(linguistic.router, tags=["Linguistic"])
|
| 74 |
app.include_router(policy.router, tags=["Policy"])
|
|
|
|
| 75 |
|
| 76 |
|
| 77 |
@app.api_route("/health", methods=["GET", "HEAD"], response_model=HealthResponse)
|
|
@@ -86,7 +87,7 @@ def health_check():
|
|
| 86 |
return HealthResponse(
|
| 87 |
status="ok" if connected else "degraded",
|
| 88 |
neo4j_connected=connected,
|
| 89 |
-
version="0.
|
| 90 |
generated_at=datetime.now().isoformat(),
|
| 91 |
)
|
| 92 |
|
|
|
|
| 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, linguistic, policy, adversarial
|
| 14 |
from api.models import HealthResponse, StatsResponse
|
| 15 |
|
| 16 |
|
|
|
|
| 34 |
"All data sourced from official government records. "
|
| 35 |
"Outputs are structural indicators, not legal findings."
|
| 36 |
),
|
| 37 |
+
version="0.26.0",
|
| 38 |
lifespan=lifespan,
|
| 39 |
)
|
| 40 |
|
|
|
|
| 72 |
app.include_router(conflict.router, tags=["Conflict"])
|
| 73 |
app.include_router(linguistic.router, tags=["Linguistic"])
|
| 74 |
app.include_router(policy.router, tags=["Policy"])
|
| 75 |
+
app.include_router(adversarial.router, tags=["Adversarial"])
|
| 76 |
|
| 77 |
|
| 78 |
@app.api_route("/health", methods=["GET", "HEAD"], response_model=HealthResponse)
|
|
|
|
| 87 |
return HealthResponse(
|
| 88 |
status="ok" if connected else "degraded",
|
| 89 |
neo4j_connected=connected,
|
| 90 |
+
version="0.26.0",
|
| 91 |
generated_at=datetime.now().isoformat(),
|
| 92 |
)
|
| 93 |
|
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.adversarial_engine import AdversarialEngine
|
| 8 |
+
from ai.risk_scorer import RiskScorer
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
engine = AdversarialEngine()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.get("/adversarial/{entity_id}")
|
| 15 |
+
def adversarial_analysis(entity_id: str, driver=Depends(get_db)):
|
| 16 |
+
logger.info(f"[Adversarial] 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 |
+
"n.risk_score AS score",
|
| 22 |
+
id=entity_id
|
| 23 |
+
).single()
|
| 24 |
+
|
| 25 |
+
if not row:
|
| 26 |
+
raise HTTPException(
|
| 27 |
+
status_code=404, detail=f"Entity {entity_id} not found"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
name = row.get("name") or entity_id
|
| 31 |
+
|
| 32 |
+
# Pull findings from risk scorer to pass into adversarial engine
|
| 33 |
+
findings = []
|
| 34 |
+
try:
|
| 35 |
+
scorer = RiskScorer(driver=driver)
|
| 36 |
+
result = scorer.score(entity_id, name)
|
| 37 |
+
findings = result.get("findings", [])
|
| 38 |
+
except Exception as e:
|
| 39 |
+
logger.warning(f"[Adversarial] Could not load findings from risk scorer: {e}")
|
| 40 |
+
|
| 41 |
+
return engine.analyze(entity_id, name, findings, driver=driver)
|