Spaces:
Running
Running
Commit ·
cb19a24
1
Parent(s): 17b1c9a
feat(ai): complete — PDF dossier generator with integrity hash
Browse filesConfirmed working:
- report_hasher: stable SHA-256 hash, unique per entity, 64 chars,
VERIFIED on correct hash, INVALID on tampered hash
- dossier_generator: Jinja2 loaded, HTML rendered 10829 chars,
HTML fallback working (WeasyPrint needs system libs on Windows,
works on Linux in production on Render.com)
- templates/dossier_en.html: Indian tricolour design, cover page
with SHA-256 hash, 8 sections, evidence locker, risk score bar
fix(api): move router registrations after app = FastAPI()
Export and multilingual routers were registered before app was
created, causing NameError on startup. Fixed import order.
Version bumped to 0.12.0 to reflect phases completed.
POST method added to CORS allow_methods for /translate endpoint.
- ai/dossier_generator.py +268 -0
- ai/report_hasher.py +121 -0
- api/main.py +12 -15
- api/routes/export.py +85 -0
- requirements.txt +2 -0
- templates/dossier_en.html +249 -0
ai/dossier_generator.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import json
|
| 4 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 5 |
+
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from loguru import logger
|
| 8 |
+
from ai.report_hasher import ReportHasher
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
TEMPLATE_PATH = os.path.join(
|
| 12 |
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
| 13 |
+
"templates", "dossier_en.html"
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class DossierGenerator:
|
| 18 |
+
|
| 19 |
+
def __init__(self, driver=None):
|
| 20 |
+
self.driver = driver
|
| 21 |
+
self.hasher = ReportHasher()
|
| 22 |
+
self._jinja = None
|
| 23 |
+
self._weasy = None
|
| 24 |
+
self._load_libs()
|
| 25 |
+
|
| 26 |
+
def _load_libs(self):
|
| 27 |
+
try:
|
| 28 |
+
import jinja2
|
| 29 |
+
self._jinja = jinja2
|
| 30 |
+
logger.success("[Dossier] Jinja2 loaded")
|
| 31 |
+
except ImportError:
|
| 32 |
+
logger.error("[Dossier] jinja2 not installed. Run: pip install jinja2")
|
| 33 |
+
|
| 34 |
+
try:
|
| 35 |
+
import weasyprint
|
| 36 |
+
self._weasy = weasyprint
|
| 37 |
+
logger.success("[Dossier] WeasyPrint loaded")
|
| 38 |
+
except Exception as e:
|
| 39 |
+
logger.warning(f"[Dossier] WeasyPrint not available: {e}")
|
| 40 |
+
logger.info("[Dossier] HTML output available. Install weasyprint for PDF.")
|
| 41 |
+
|
| 42 |
+
def assemble_dossier_data(self, entity_id: str,
|
| 43 |
+
multi_report: dict = None) -> dict:
|
| 44 |
+
generated_at = datetime.now().isoformat()
|
| 45 |
+
report_hash = self.hasher.generate_hash(entity_id, generated_at)
|
| 46 |
+
|
| 47 |
+
overview = {}
|
| 48 |
+
entity_type = "Unknown"
|
| 49 |
+
entity_name = entity_id
|
| 50 |
+
|
| 51 |
+
if self.driver:
|
| 52 |
+
try:
|
| 53 |
+
with self.driver.session() as session:
|
| 54 |
+
row = session.run(
|
| 55 |
+
"""
|
| 56 |
+
MATCH (n {id: $id})
|
| 57 |
+
RETURN n.name AS name, labels(n)[0] AS type,
|
| 58 |
+
n.party AS party, n.state AS state,
|
| 59 |
+
n.total_assets AS assets,
|
| 60 |
+
n.risk_score AS risk_score,
|
| 61 |
+
n.risk_level AS risk_level
|
| 62 |
+
""",
|
| 63 |
+
id=entity_id
|
| 64 |
+
).single()
|
| 65 |
+
if row:
|
| 66 |
+
entity_name = row["name"] or entity_id
|
| 67 |
+
entity_type = row["type"] or "Unknown"
|
| 68 |
+
overview = {
|
| 69 |
+
"party": row.get("party"),
|
| 70 |
+
"state": row.get("state"),
|
| 71 |
+
"assets": row.get("assets"),
|
| 72 |
+
"risk_score": row.get("risk_score", 0),
|
| 73 |
+
"risk_level": row.get("risk_level", "UNKNOWN"),
|
| 74 |
+
}
|
| 75 |
+
except Exception as e:
|
| 76 |
+
logger.warning(f"[Dossier] Neo4j query failed: {e}")
|
| 77 |
+
|
| 78 |
+
if multi_report:
|
| 79 |
+
entity_name = multi_report.get("entity_name", entity_name)
|
| 80 |
+
|
| 81 |
+
agreed_findings = []
|
| 82 |
+
doubts = []
|
| 83 |
+
positive_contributions = []
|
| 84 |
+
timeline = []
|
| 85 |
+
evidence_locker = []
|
| 86 |
+
investigator_count = 12
|
| 87 |
+
|
| 88 |
+
if multi_report:
|
| 89 |
+
agreed_findings = multi_report.get("agreed_findings", [])
|
| 90 |
+
doubts = multi_report.get("doubts", [])
|
| 91 |
+
positive_contributions = multi_report.get("positive_contributions", [])
|
| 92 |
+
timeline = multi_report.get("timeline", [])
|
| 93 |
+
evidence_locker = multi_report.get("evidence_locker", [])
|
| 94 |
+
investigator_count = multi_report.get("investigator_count", 12)
|
| 95 |
+
|
| 96 |
+
if self.driver and not self.hasher.store_hash(
|
| 97 |
+
report_hash, entity_id, generated_at, self.driver
|
| 98 |
+
):
|
| 99 |
+
logger.info("[Dossier] Hash storage skipped — no live driver")
|
| 100 |
+
|
| 101 |
+
risk_score = int(overview.get("risk_score") or 0)
|
| 102 |
+
risk_level = overview.get("risk_level") or "UNKNOWN"
|
| 103 |
+
|
| 104 |
+
return {
|
| 105 |
+
"entity_id": entity_id,
|
| 106 |
+
"entity_name": entity_name,
|
| 107 |
+
"entity_type": entity_type,
|
| 108 |
+
"report_hash": report_hash,
|
| 109 |
+
"generated_at": generated_at[:19].replace("T", " "),
|
| 110 |
+
"investigator_count": investigator_count,
|
| 111 |
+
"evidence_count": len(evidence_locker),
|
| 112 |
+
"overview": overview,
|
| 113 |
+
"risk_score": risk_score,
|
| 114 |
+
"risk_level": risk_level,
|
| 115 |
+
"agreed_findings": agreed_findings,
|
| 116 |
+
"doubts": doubts,
|
| 117 |
+
"positive_contributions": positive_contributions,
|
| 118 |
+
"timeline": timeline,
|
| 119 |
+
"evidence_locker": evidence_locker,
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
def render_html(self, dossier_data: dict) -> str:
|
| 123 |
+
if not self._jinja:
|
| 124 |
+
logger.error("[Dossier] Jinja2 not available — cannot render HTML")
|
| 125 |
+
return ""
|
| 126 |
+
|
| 127 |
+
if not os.path.exists(TEMPLATE_PATH):
|
| 128 |
+
logger.error(f"[Dossier] Template not found: {TEMPLATE_PATH}")
|
| 129 |
+
return ""
|
| 130 |
+
|
| 131 |
+
env = self._jinja.Environment(
|
| 132 |
+
loader=self._jinja.FileSystemLoader(
|
| 133 |
+
os.path.dirname(TEMPLATE_PATH)
|
| 134 |
+
)
|
| 135 |
+
)
|
| 136 |
+
template = env.get_template(os.path.basename(TEMPLATE_PATH))
|
| 137 |
+
html = template.render(**dossier_data)
|
| 138 |
+
logger.success(
|
| 139 |
+
f"[Dossier] HTML rendered for {dossier_data['entity_name']} "
|
| 140 |
+
f"({len(html):,} chars)"
|
| 141 |
+
)
|
| 142 |
+
return html
|
| 143 |
+
|
| 144 |
+
def render_pdf(self, html: str, output_path: str) -> bool:
|
| 145 |
+
if not self._weasy:
|
| 146 |
+
logger.warning(
|
| 147 |
+
"[Dossier] WeasyPrint not available — "
|
| 148 |
+
"saving HTML instead of PDF"
|
| 149 |
+
)
|
| 150 |
+
html_path = output_path.replace(".pdf", ".html")
|
| 151 |
+
with open(html_path, "w", encoding="utf-8") as f:
|
| 152 |
+
f.write(html)
|
| 153 |
+
logger.info(f"[Dossier] HTML saved to: {html_path}")
|
| 154 |
+
return True
|
| 155 |
+
|
| 156 |
+
try:
|
| 157 |
+
self._weasy.HTML(string=html).write_pdf(output_path)
|
| 158 |
+
size_kb = os.path.getsize(output_path) // 1024
|
| 159 |
+
logger.success(
|
| 160 |
+
f"[Dossier] PDF saved: {output_path} ({size_kb} KB)"
|
| 161 |
+
)
|
| 162 |
+
return True
|
| 163 |
+
except Exception as e:
|
| 164 |
+
logger.error(f"[Dossier] PDF generation failed: {e}")
|
| 165 |
+
return False
|
| 166 |
+
|
| 167 |
+
def generate(self, entity_id: str, multi_report: dict = None,
|
| 168 |
+
output_dir: str = "data/processed") -> dict:
|
| 169 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 170 |
+
dossier_data = self.assemble_dossier_data(entity_id, multi_report)
|
| 171 |
+
html = self.render_html(dossier_data)
|
| 172 |
+
|
| 173 |
+
result = {
|
| 174 |
+
"entity_id": entity_id,
|
| 175 |
+
"entity_name": dossier_data["entity_name"],
|
| 176 |
+
"report_hash": dossier_data["report_hash"],
|
| 177 |
+
"generated_at":dossier_data["generated_at"],
|
| 178 |
+
"html_ready": bool(html),
|
| 179 |
+
"pdf_path": None,
|
| 180 |
+
"html_path": None,
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
if html:
|
| 184 |
+
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 185 |
+
clean_name = "".join(
|
| 186 |
+
c if c.isalnum() else "_"
|
| 187 |
+
for c in dossier_data["entity_name"]
|
| 188 |
+
)[:30]
|
| 189 |
+
pdf_path = os.path.join(
|
| 190 |
+
output_dir, f"dossier_{clean_name}_{ts}.pdf"
|
| 191 |
+
)
|
| 192 |
+
success = self.render_pdf(html, pdf_path)
|
| 193 |
+
if success:
|
| 194 |
+
actual_path = pdf_path if os.path.exists(pdf_path) \
|
| 195 |
+
else pdf_path.replace(".pdf", ".html")
|
| 196 |
+
result["pdf_path"] = actual_path
|
| 197 |
+
result["html_path"] = pdf_path.replace(".pdf", ".html") \
|
| 198 |
+
if not os.path.exists(pdf_path) else None
|
| 199 |
+
|
| 200 |
+
return result
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
if __name__ == "__main__":
|
| 204 |
+
print("=" * 55)
|
| 205 |
+
print("BharatGraph - Dossier Generator Test")
|
| 206 |
+
print("=" * 55)
|
| 207 |
+
|
| 208 |
+
generator = DossierGenerator(driver=None)
|
| 209 |
+
|
| 210 |
+
sample_report = {
|
| 211 |
+
"entity_name": "Sample Politician",
|
| 212 |
+
"investigator_count": 12,
|
| 213 |
+
"agreed_findings": [
|
| 214 |
+
{
|
| 215 |
+
"type": "contract_financial_flow",
|
| 216 |
+
"description": "Entity linked to 3 contracts totalling Rs 150 Cr.",
|
| 217 |
+
"severity": "HIGH",
|
| 218 |
+
"confidence": "HIGH",
|
| 219 |
+
"evidence": ["Contract order from GeM"],
|
| 220 |
+
}
|
| 221 |
+
],
|
| 222 |
+
"doubts": [
|
| 223 |
+
{
|
| 224 |
+
"hypothesis": "Company directorships exist but no matching contracts found.",
|
| 225 |
+
"gap": "Possible CPPP data coverage gap",
|
| 226 |
+
"action": "Cross-reference with CPPP tender awards",
|
| 227 |
+
}
|
| 228 |
+
],
|
| 229 |
+
"positive_contributions": [
|
| 230 |
+
"Party affiliation recorded providing public accountability.",
|
| 231 |
+
"No matches found in ICIJ Offshore Leaks database.",
|
| 232 |
+
],
|
| 233 |
+
"timeline": [
|
| 234 |
+
{
|
| 235 |
+
"date": "2022-04-15",
|
| 236 |
+
"event": "Contract Awarded",
|
| 237 |
+
"detail": "Rs 50 Cr from Ministry of Rural Development",
|
| 238 |
+
"source": "GeM",
|
| 239 |
+
}
|
| 240 |
+
],
|
| 241 |
+
"evidence_locker": [
|
| 242 |
+
{
|
| 243 |
+
"institution": "Government e-Marketplace",
|
| 244 |
+
"document": "Contract Order Records",
|
| 245 |
+
"url": "https://gem.gov.in",
|
| 246 |
+
"date": "2022-04-15",
|
| 247 |
+
},
|
| 248 |
+
{
|
| 249 |
+
"institution": "Election Commission of India",
|
| 250 |
+
"document": "Candidate Affidavit",
|
| 251 |
+
"url": "https://myneta.info",
|
| 252 |
+
"date": "2024-04-01",
|
| 253 |
+
},
|
| 254 |
+
],
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
result = generator.generate(
|
| 258 |
+
"sample_entity_001",
|
| 259 |
+
multi_report=sample_report,
|
| 260 |
+
output_dir="data/processed",
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
print(f"\n Entity: {result['entity_name']}")
|
| 264 |
+
print(f" Report hash: {result['report_hash'][:32]}...")
|
| 265 |
+
print(f" HTML ready: {result['html_ready']}")
|
| 266 |
+
print(f" Output path: {result.get('pdf_path') or result.get('html_path')}")
|
| 267 |
+
|
| 268 |
+
print("\nDone!")
|
ai/report_hasher.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 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 |
+
|
| 11 |
+
class ReportHasher:
|
| 12 |
+
|
| 13 |
+
def generate_hash(self, entity_id: str, generated_at: str,
|
| 14 |
+
data_snapshot: dict = None) -> str:
|
| 15 |
+
content = f"{entity_id}|{generated_at}"
|
| 16 |
+
if data_snapshot:
|
| 17 |
+
snapshot_str = json.dumps(data_snapshot, sort_keys=True,
|
| 18 |
+
ensure_ascii=False)
|
| 19 |
+
content += f"|{snapshot_str}"
|
| 20 |
+
report_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
| 21 |
+
logger.info(f"[ReportHasher] Hash: {report_hash[:16]}... for {entity_id}")
|
| 22 |
+
return report_hash
|
| 23 |
+
|
| 24 |
+
def verify_hash(self, entity_id: str, generated_at: str,
|
| 25 |
+
claimed_hash: str,
|
| 26 |
+
data_snapshot: dict = None) -> dict:
|
| 27 |
+
computed = self.generate_hash(entity_id, generated_at, data_snapshot)
|
| 28 |
+
is_valid = computed == claimed_hash
|
| 29 |
+
return {
|
| 30 |
+
"valid": is_valid,
|
| 31 |
+
"entity_id": entity_id,
|
| 32 |
+
"claimed_hash": claimed_hash,
|
| 33 |
+
"computed_hash": computed,
|
| 34 |
+
"verified_at": datetime.now().isoformat(),
|
| 35 |
+
"status": (
|
| 36 |
+
"VERIFIED — Report integrity confirmed. "
|
| 37 |
+
"Hash matches original generation."
|
| 38 |
+
if is_valid else
|
| 39 |
+
"INVALID — Hash mismatch. Report may have been modified."
|
| 40 |
+
),
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
def store_hash(self, report_hash: str, entity_id: str,
|
| 44 |
+
generated_at: str, driver) -> bool:
|
| 45 |
+
if not driver:
|
| 46 |
+
return False
|
| 47 |
+
try:
|
| 48 |
+
with driver.session() as session:
|
| 49 |
+
session.run(
|
| 50 |
+
"""
|
| 51 |
+
MERGE (h:ReportHash {hash: $hash})
|
| 52 |
+
SET h.entity_id = $entity_id,
|
| 53 |
+
h.generated_at = $generated_at,
|
| 54 |
+
h.stored_at = $stored_at
|
| 55 |
+
""",
|
| 56 |
+
hash=report_hash,
|
| 57 |
+
entity_id=entity_id,
|
| 58 |
+
generated_at=generated_at,
|
| 59 |
+
stored_at=datetime.now().isoformat(),
|
| 60 |
+
)
|
| 61 |
+
logger.success(
|
| 62 |
+
f"[ReportHasher] Stored hash {report_hash[:16]}... in Neo4j"
|
| 63 |
+
)
|
| 64 |
+
return True
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error(f"[ReportHasher] Failed to store hash: {e}")
|
| 67 |
+
return False
|
| 68 |
+
|
| 69 |
+
def lookup_hash(self, report_hash: str, driver) -> dict:
|
| 70 |
+
if not driver:
|
| 71 |
+
return {"found": False, "message": "No database connection"}
|
| 72 |
+
try:
|
| 73 |
+
with driver.session() as session:
|
| 74 |
+
row = session.run(
|
| 75 |
+
"""
|
| 76 |
+
MATCH (h:ReportHash {hash: $hash})
|
| 77 |
+
RETURN h.entity_id AS entity_id,
|
| 78 |
+
h.generated_at AS generated_at,
|
| 79 |
+
h.stored_at AS stored_at
|
| 80 |
+
""",
|
| 81 |
+
hash=report_hash
|
| 82 |
+
).single()
|
| 83 |
+
if row:
|
| 84 |
+
return {
|
| 85 |
+
"found": True,
|
| 86 |
+
"entity_id": row["entity_id"],
|
| 87 |
+
"generated_at": row["generated_at"],
|
| 88 |
+
"stored_at": row["stored_at"],
|
| 89 |
+
"status": "Hash found in BharatGraph registry",
|
| 90 |
+
}
|
| 91 |
+
return {"found": False, "status": "Hash not found in registry"}
|
| 92 |
+
except Exception as e:
|
| 93 |
+
return {"found": False, "error": str(e)}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
if __name__ == "__main__":
|
| 97 |
+
print("=" * 55)
|
| 98 |
+
print("BharatGraph - Report Hasher Test")
|
| 99 |
+
print("=" * 55)
|
| 100 |
+
|
| 101 |
+
hasher = ReportHasher()
|
| 102 |
+
|
| 103 |
+
h1 = hasher.generate_hash("P001", "2026-04-01T10:00:00")
|
| 104 |
+
h2 = hasher.generate_hash("P001", "2026-04-01T10:00:00")
|
| 105 |
+
h3 = hasher.generate_hash("P002", "2026-04-01T10:00:00")
|
| 106 |
+
|
| 107 |
+
print(f"\n Hash 1: {h1}")
|
| 108 |
+
print(f" Hash 2: {h2}")
|
| 109 |
+
print(f" Stable: {h1 == h2}")
|
| 110 |
+
print(f" Unique: {h1 != h3}")
|
| 111 |
+
print(f" Length: {len(h1)} chars")
|
| 112 |
+
|
| 113 |
+
result = hasher.verify_hash("P001", "2026-04-01T10:00:00", h1)
|
| 114 |
+
print(f"\n Verification (correct hash): {result['valid']}")
|
| 115 |
+
print(f" Status: {result['status']}")
|
| 116 |
+
|
| 117 |
+
result2 = hasher.verify_hash("P001", "2026-04-01T10:00:00", "tampered_hash")
|
| 118 |
+
print(f"\n Verification (tampered hash): {result2['valid']}")
|
| 119 |
+
print(f" Status: {result2['status']}")
|
| 120 |
+
|
| 121 |
+
print("\nDone!")
|
api/main.py
CHANGED
|
@@ -8,11 +8,9 @@ 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
|
| 12 |
from api.models import HealthResponse, StatsResponse
|
| 13 |
|
| 14 |
-
from api.routes import multilingual
|
| 15 |
-
app.include_router(multilingual.router, tags=["Multilingual"])
|
| 16 |
|
| 17 |
@asynccontextmanager
|
| 18 |
async def lifespan(app: FastAPI):
|
|
@@ -30,26 +28,28 @@ app = FastAPI(
|
|
| 30 |
"All data sourced from official government records. "
|
| 31 |
"Outputs are structural indicators, not legal findings."
|
| 32 |
),
|
| 33 |
-
version="0.
|
| 34 |
lifespan=lifespan,
|
| 35 |
)
|
| 36 |
|
| 37 |
app.add_middleware(
|
| 38 |
CORSMiddleware,
|
| 39 |
allow_origins=["*"],
|
| 40 |
-
allow_methods=["GET"],
|
| 41 |
allow_headers=["*"],
|
| 42 |
)
|
| 43 |
|
| 44 |
-
app.include_router(search.router,
|
| 45 |
-
app.include_router(profile.router,
|
| 46 |
-
app.include_router(graph.router,
|
| 47 |
-
app.include_router(risk.router,
|
|
|
|
|
|
|
| 48 |
|
| 49 |
|
| 50 |
@app.get("/health", response_model=HealthResponse)
|
| 51 |
def health_check():
|
| 52 |
-
driver
|
| 53 |
connected = driver is not None
|
| 54 |
try:
|
| 55 |
if driver:
|
|
@@ -59,29 +59,26 @@ def health_check():
|
|
| 59 |
return HealthResponse(
|
| 60 |
status="ok" if connected else "degraded",
|
| 61 |
neo4j_connected=connected,
|
| 62 |
-
version="0.
|
| 63 |
generated_at=datetime.now().isoformat(),
|
| 64 |
)
|
| 65 |
|
| 66 |
|
| 67 |
@app.get("/stats", response_model=StatsResponse)
|
| 68 |
def get_stats():
|
| 69 |
-
driver
|
| 70 |
node_counts = {}
|
| 71 |
rel_counts = {}
|
| 72 |
-
|
| 73 |
if driver:
|
| 74 |
with driver.session() as session:
|
| 75 |
rows = session.run(
|
| 76 |
"MATCH (n) RETURN labels(n)[0] AS t, count(n) AS c"
|
| 77 |
).data()
|
| 78 |
node_counts = {r["t"]: r["c"] for r in rows if r["t"]}
|
| 79 |
-
|
| 80 |
rows = session.run(
|
| 81 |
"MATCH ()-[r]->() RETURN type(r) AS t, count(r) AS c"
|
| 82 |
).data()
|
| 83 |
rel_counts = {r["t"]: r["c"] for r in rows if r["t"]}
|
| 84 |
-
|
| 85 |
return StatsResponse(
|
| 86 |
nodes=node_counts,
|
| 87 |
relationships=rel_counts,
|
|
|
|
| 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
|
| 12 |
from api.models import HealthResponse, StatsResponse
|
| 13 |
|
|
|
|
|
|
|
| 14 |
|
| 15 |
@asynccontextmanager
|
| 16 |
async def lifespan(app: FastAPI):
|
|
|
|
| 28 |
"All data sourced from official government records. "
|
| 29 |
"Outputs are structural indicators, not legal findings."
|
| 30 |
),
|
| 31 |
+
version="0.12.0",
|
| 32 |
lifespan=lifespan,
|
| 33 |
)
|
| 34 |
|
| 35 |
app.add_middleware(
|
| 36 |
CORSMiddleware,
|
| 37 |
allow_origins=["*"],
|
| 38 |
+
allow_methods=["GET", "POST"],
|
| 39 |
allow_headers=["*"],
|
| 40 |
)
|
| 41 |
|
| 42 |
+
app.include_router(search.router, tags=["Search"])
|
| 43 |
+
app.include_router(profile.router, tags=["Profile"])
|
| 44 |
+
app.include_router(graph.router, tags=["Graph"])
|
| 45 |
+
app.include_router(risk.router, tags=["Risk"])
|
| 46 |
+
app.include_router(multilingual.router,tags=["Multilingual"])
|
| 47 |
+
app.include_router(export.router, tags=["Export"])
|
| 48 |
|
| 49 |
|
| 50 |
@app.get("/health", response_model=HealthResponse)
|
| 51 |
def health_check():
|
| 52 |
+
driver = get_driver()
|
| 53 |
connected = driver is not None
|
| 54 |
try:
|
| 55 |
if driver:
|
|
|
|
| 59 |
return HealthResponse(
|
| 60 |
status="ok" if connected else "degraded",
|
| 61 |
neo4j_connected=connected,
|
| 62 |
+
version="0.12.0",
|
| 63 |
generated_at=datetime.now().isoformat(),
|
| 64 |
)
|
| 65 |
|
| 66 |
|
| 67 |
@app.get("/stats", response_model=StatsResponse)
|
| 68 |
def get_stats():
|
| 69 |
+
driver = get_driver()
|
| 70 |
node_counts = {}
|
| 71 |
rel_counts = {}
|
|
|
|
| 72 |
if driver:
|
| 73 |
with driver.session() as session:
|
| 74 |
rows = session.run(
|
| 75 |
"MATCH (n) RETURN labels(n)[0] AS t, count(n) AS c"
|
| 76 |
).data()
|
| 77 |
node_counts = {r["t"]: r["c"] for r in rows if r["t"]}
|
|
|
|
| 78 |
rows = session.run(
|
| 79 |
"MATCH ()-[r]->() RETURN type(r) AS t, count(r) AS c"
|
| 80 |
).data()
|
| 81 |
rel_counts = {r["t"]: r["c"] for r in rows if r["t"]}
|
|
|
|
| 82 |
return StatsResponse(
|
| 83 |
nodes=node_counts,
|
| 84 |
relationships=rel_counts,
|
api/routes/export.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
| 4 |
+
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 7 |
+
from fastapi.responses import FileResponse
|
| 8 |
+
from loguru import logger
|
| 9 |
+
|
| 10 |
+
from api.dependencies import get_db, get_driver
|
| 11 |
+
from ai.dossier_generator import DossierGenerator
|
| 12 |
+
from ai.report_hasher import ReportHasher
|
| 13 |
+
from ai.multi_investigator import MultiInvestigator
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
_generator = None
|
| 17 |
+
_hasher = None
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def get_generator() -> DossierGenerator:
|
| 21 |
+
global _generator
|
| 22 |
+
if _generator is None:
|
| 23 |
+
_generator = DossierGenerator(driver=get_driver())
|
| 24 |
+
return _generator
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_hasher() -> ReportHasher:
|
| 28 |
+
global _hasher
|
| 29 |
+
if _hasher is None:
|
| 30 |
+
_hasher = ReportHasher()
|
| 31 |
+
return _hasher
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.get("/export/pdf/{entity_id}")
|
| 35 |
+
def export_pdf(entity_id: str, driver=Depends(get_db)):
|
| 36 |
+
logger.info(f"[Export] PDF requested for: {entity_id}")
|
| 37 |
+
|
| 38 |
+
investigator = MultiInvestigator(driver=driver)
|
| 39 |
+
with driver.session() as session:
|
| 40 |
+
row = session.run(
|
| 41 |
+
"MATCH (n {id: $id}) RETURN n.name AS name",
|
| 42 |
+
id=entity_id
|
| 43 |
+
).single()
|
| 44 |
+
|
| 45 |
+
if not row:
|
| 46 |
+
raise HTTPException(status_code=404,
|
| 47 |
+
detail=f"Entity {entity_id} not found")
|
| 48 |
+
|
| 49 |
+
entity_name = row["name"] or entity_id
|
| 50 |
+
multi_report = investigator.investigate(entity_id, entity_name)
|
| 51 |
+
|
| 52 |
+
generator = DossierGenerator(driver=driver)
|
| 53 |
+
result = generator.generate(
|
| 54 |
+
entity_id,
|
| 55 |
+
multi_report=multi_report,
|
| 56 |
+
output_dir="data/processed",
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
output_path = result.get("pdf_path") or result.get("html_path")
|
| 60 |
+
if not output_path or not os.path.exists(output_path):
|
| 61 |
+
raise HTTPException(status_code=500,
|
| 62 |
+
detail="Dossier generation failed")
|
| 63 |
+
|
| 64 |
+
media_type = (
|
| 65 |
+
"application/pdf"
|
| 66 |
+
if output_path.endswith(".pdf")
|
| 67 |
+
else "text/html"
|
| 68 |
+
)
|
| 69 |
+
return FileResponse(
|
| 70 |
+
output_path,
|
| 71 |
+
media_type=media_type,
|
| 72 |
+
filename=os.path.basename(output_path),
|
| 73 |
+
headers={"X-Report-Hash": result["report_hash"]},
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.get("/verify/{report_hash}")
|
| 78 |
+
def verify_report(report_hash: str, driver=Depends(get_db)):
|
| 79 |
+
hasher = get_hasher()
|
| 80 |
+
result = hasher.lookup_hash(report_hash, driver)
|
| 81 |
+
return {
|
| 82 |
+
**result,
|
| 83 |
+
"hash": report_hash,
|
| 84 |
+
"verified_at": datetime.now().isoformat(),
|
| 85 |
+
}
|
requirements.txt
CHANGED
|
@@ -14,3 +14,5 @@ neo4j>=5.14.0
|
|
| 14 |
spacy>=3.7.0
|
| 15 |
sentence-transformers>=2.6.0
|
| 16 |
networkx>=3.2.0
|
|
|
|
|
|
|
|
|
| 14 |
spacy>=3.7.0
|
| 15 |
sentence-transformers>=2.6.0
|
| 16 |
networkx>=3.2.0
|
| 17 |
+
weasyprint>=60.0
|
| 18 |
+
jinja2>=3.1.0
|
templates/dossier_en.html
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<title>BharatGraph Investigation Dossier</title>
|
| 6 |
+
<style>
|
| 7 |
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
| 8 |
+
body { font-family: "DejaVu Sans", Arial, sans-serif; font-size: 11px; color: #1a1a2e; background: white; }
|
| 9 |
+
.cover { page-break-after: always; min-height: 297mm; display: flex; flex-direction: column; justify-content: center; align-items: center; padding: 40mm 30mm; border-bottom: 6px solid #FF9933; }
|
| 10 |
+
.cover-logo { font-size: 28px; font-weight: bold; color: #138808; letter-spacing: 3px; margin-bottom: 8px; }
|
| 11 |
+
.cover-tagline { font-size: 11px; color: #666; letter-spacing: 2px; text-transform: uppercase; margin-bottom: 40px; }
|
| 12 |
+
.cover-title { font-size: 22px; font-weight: bold; color: #0A0F2E; text-align: center; margin-bottom: 8px; }
|
| 13 |
+
.cover-entity { font-size: 16px; color: #FF9933; text-align: center; margin-bottom: 40px; }
|
| 14 |
+
.cover-meta { font-size: 9px; color: #888; text-align: center; line-height: 1.8; margin-bottom: 30px; }
|
| 15 |
+
.cover-hash { font-family: "Courier New", monospace; font-size: 9px; background: #f5f5f5; padding: 8px 16px; border: 1px solid #ddd; border-radius: 3px; word-break: break-all; text-align: center; max-width: 400px; }
|
| 16 |
+
.cover-hash-label { font-size: 8px; color: #999; text-align: center; margin-top: 4px; }
|
| 17 |
+
.disclaimer-box { margin-top: 40px; padding: 12px 20px; border: 1px solid #FF9933; border-radius: 3px; font-size: 9px; color: #666; text-align: center; max-width: 450px; }
|
| 18 |
+
.section { page-break-inside: avoid; padding: 16px 24px; margin-bottom: 16px; }
|
| 19 |
+
.section-header { font-size: 13px; font-weight: bold; color: white; background: #0A0F2E; padding: 8px 16px; margin-bottom: 12px; border-left: 4px solid #FF9933; }
|
| 20 |
+
.section-content { line-height: 1.7; }
|
| 21 |
+
.subsection-title { font-size: 10px; font-weight: bold; color: #138808; margin: 10px 0 4px 0; text-transform: uppercase; letter-spacing: 1px; }
|
| 22 |
+
table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 9px; }
|
| 23 |
+
th { background: #0A0F2E; color: white; padding: 6px 8px; text-align: left; font-weight: normal; }
|
| 24 |
+
td { padding: 5px 8px; border-bottom: 1px solid #eee; }
|
| 25 |
+
tr:nth-child(even) td { background: #f9f9f9; }
|
| 26 |
+
.risk-badge { display: inline-block; padding: 3px 10px; border-radius: 3px; font-size: 10px; font-weight: bold; }
|
| 27 |
+
.risk-LOW { background: #d4edda; color: #155724; }
|
| 28 |
+
.risk-MODERATE { background: #fff3cd; color: #856404; }
|
| 29 |
+
.risk-HIGH { background: #f8d7da; color: #721c24; }
|
| 30 |
+
.risk-VERY_HIGH { background: #f5c6cb; color: #491217; }
|
| 31 |
+
.finding-item { padding: 8px 12px; margin: 6px 0; border-left: 3px solid #FF9933; background: #fffaf5; }
|
| 32 |
+
.finding-HIGH { border-left-color: #dc3545; background: #fff5f5; }
|
| 33 |
+
.finding-MODERATE { border-left-color: #ffc107; background: #fffdf5; }
|
| 34 |
+
.finding-LOW { border-left-color: #28a745; background: #f5fff8; }
|
| 35 |
+
.evidence-item { padding: 6px 10px; margin: 4px 0; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 2px; font-size: 9px; }
|
| 36 |
+
.doubt-item { padding: 8px 12px; margin: 6px 0; border-left: 3px solid #6c757d; background: #f8f9fa; font-style: italic; }
|
| 37 |
+
.positive-item { padding: 6px 10px; margin: 4px 0; border-left: 3px solid #28a745; background: #f5fff8; }
|
| 38 |
+
.timeline-item { display: flex; padding: 6px 0; border-bottom: 1px solid #eee; }
|
| 39 |
+
.timeline-date { width: 100px; font-size: 9px; color: #666; flex-shrink: 0; }
|
| 40 |
+
.timeline-event { font-size: 10px; }
|
| 41 |
+
.page-footer { position: fixed; bottom: 0; left: 0; right: 0; font-size: 8px; color: #aaa; text-align: center; padding: 4px; border-top: 1px solid #eee; }
|
| 42 |
+
.score-bar-container { margin: 8px 0; }
|
| 43 |
+
.score-bar { height: 12px; background: #eee; border-radius: 6px; overflow: hidden; }
|
| 44 |
+
.score-fill { height: 100%; border-radius: 6px; }
|
| 45 |
+
.score-fill-LOW { background: #28a745; }
|
| 46 |
+
.score-fill-MODERATE { background: #ffc107; }
|
| 47 |
+
.score-fill-HIGH { background: #fd7e14; }
|
| 48 |
+
.score-fill-VERY_HIGH { background: #dc3545; }
|
| 49 |
+
.metadata-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin: 8px 0; }
|
| 50 |
+
.metadata-item { padding: 6px 10px; background: #f8f9fa; border-radius: 2px; }
|
| 51 |
+
.metadata-label { font-size: 8px; color: #666; text-transform: uppercase; }
|
| 52 |
+
.metadata-value { font-size: 10px; font-weight: bold; color: #1a1a2e; }
|
| 53 |
+
h1 { font-size: 16px; margin-bottom: 12px; color: #0A0F2E; }
|
| 54 |
+
h2 { font-size: 12px; margin: 10px 0 6px 0; color: #138808; }
|
| 55 |
+
p { margin-bottom: 6px; }
|
| 56 |
+
.source-chip { display: inline-block; padding: 2px 6px; background: #e9ecef; border-radius: 2px; font-size: 8px; color: #495057; margin: 2px; }
|
| 57 |
+
</style>
|
| 58 |
+
</head>
|
| 59 |
+
<body>
|
| 60 |
+
|
| 61 |
+
<div class="cover">
|
| 62 |
+
<div class="cover-logo">BHARATGRAPH</div>
|
| 63 |
+
<div class="cover-tagline">Public Transparency Intelligence Platform</div>
|
| 64 |
+
<div class="cover-title">Investigation Dossier</div>
|
| 65 |
+
<div class="cover-entity">{{ entity_name }}</div>
|
| 66 |
+
<div class="cover-meta">
|
| 67 |
+
Entity Type: {{ entity_type }}<br>
|
| 68 |
+
Entity ID: {{ entity_id }}<br>
|
| 69 |
+
Generated: {{ generated_at }}<br>
|
| 70 |
+
Investigators: {{ investigator_count }}<br>
|
| 71 |
+
Evidence Items: {{ evidence_count }}
|
| 72 |
+
</div>
|
| 73 |
+
<div class="cover-hash">{{ report_hash }}</div>
|
| 74 |
+
<div class="cover-hash-label">SHA-256 Report Integrity Hash — verify at bharatgraph.in/verify/{{ report_hash[:16] }}</div>
|
| 75 |
+
<div class="disclaimer-box">
|
| 76 |
+
This dossier is generated from official public records only. Every claim is linked to a primary source document.
|
| 77 |
+
This is an analytical report. It does not constitute a legal finding, accusation, or determination of guilt.
|
| 78 |
+
The subject of this report has the right to submit corrections through the BharatGraph platform.
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
|
| 82 |
+
<div class="section">
|
| 83 |
+
<div class="section-header">Section 1 — Identity and Overview</div>
|
| 84 |
+
<div class="section-content">
|
| 85 |
+
<div class="metadata-grid">
|
| 86 |
+
<div class="metadata-item">
|
| 87 |
+
<div class="metadata-label">Full Name</div>
|
| 88 |
+
<div class="metadata-value">{{ entity_name }}</div>
|
| 89 |
+
</div>
|
| 90 |
+
<div class="metadata-item">
|
| 91 |
+
<div class="metadata-label">Entity Type</div>
|
| 92 |
+
<div class="metadata-value">{{ entity_type }}</div>
|
| 93 |
+
</div>
|
| 94 |
+
{% if overview.party %}
|
| 95 |
+
<div class="metadata-item">
|
| 96 |
+
<div class="metadata-label">Party Affiliation</div>
|
| 97 |
+
<div class="metadata-value">{{ overview.party }}</div>
|
| 98 |
+
</div>
|
| 99 |
+
{% endif %}
|
| 100 |
+
{% if overview.state %}
|
| 101 |
+
<div class="metadata-item">
|
| 102 |
+
<div class="metadata-label">State</div>
|
| 103 |
+
<div class="metadata-value">{{ overview.state }}</div>
|
| 104 |
+
</div>
|
| 105 |
+
{% endif %}
|
| 106 |
+
</div>
|
| 107 |
+
</div>
|
| 108 |
+
</div>
|
| 109 |
+
|
| 110 |
+
<div class="section">
|
| 111 |
+
<div class="section-header">Section 2 — Structural Risk Indicator</div>
|
| 112 |
+
<div class="section-content">
|
| 113 |
+
<div class="metadata-grid">
|
| 114 |
+
<div class="metadata-item">
|
| 115 |
+
<div class="metadata-label">Risk Score</div>
|
| 116 |
+
<div class="metadata-value">{{ risk_score }} / 100</div>
|
| 117 |
+
</div>
|
| 118 |
+
<div class="metadata-item">
|
| 119 |
+
<div class="metadata-label">Risk Level</div>
|
| 120 |
+
<div class="metadata-value">
|
| 121 |
+
<span class="risk-badge risk-{{ risk_level }}">{{ risk_level }}</span>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
</div>
|
| 125 |
+
<div class="score-bar-container">
|
| 126 |
+
<div class="score-bar">
|
| 127 |
+
<div class="score-fill score-fill-{{ risk_level }}" style="width: {{ risk_score }}%"></div>
|
| 128 |
+
</div>
|
| 129 |
+
</div>
|
| 130 |
+
<p style="margin-top:8px; font-size:10px; color:#666;">
|
| 131 |
+
This score is derived from structural patterns in official data.
|
| 132 |
+
It is an analytical indicator, not a legal determination.
|
| 133 |
+
</p>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div class="section">
|
| 138 |
+
<div class="section-header">Section 3 — Analytical Findings</div>
|
| 139 |
+
<div class="section-content">
|
| 140 |
+
{% if agreed_findings %}
|
| 141 |
+
<div class="subsection-title">Agreed Findings (confirmed by multiple investigators)</div>
|
| 142 |
+
{% for finding in agreed_findings %}
|
| 143 |
+
<div class="finding-item finding-{{ finding.severity }}">
|
| 144 |
+
<strong>[{{ finding.severity }}]</strong> {{ finding.description }}
|
| 145 |
+
{% if finding.evidence %}
|
| 146 |
+
<div style="margin-top:4px;">
|
| 147 |
+
{% for e in finding.evidence[:2] %}
|
| 148 |
+
<span class="source-chip">{{ e }}</span>
|
| 149 |
+
{% endfor %}
|
| 150 |
+
</div>
|
| 151 |
+
{% endif %}
|
| 152 |
+
</div>
|
| 153 |
+
{% endfor %}
|
| 154 |
+
{% else %}
|
| 155 |
+
<p>No findings confirmed by multiple investigators in current dataset.</p>
|
| 156 |
+
{% endif %}
|
| 157 |
+
</div>
|
| 158 |
+
</div>
|
| 159 |
+
|
| 160 |
+
<div class="section">
|
| 161 |
+
<div class="section-header">Section 4 — Doubts and Unexplained Patterns</div>
|
| 162 |
+
<div class="section-content">
|
| 163 |
+
{% if doubts %}
|
| 164 |
+
{% for doubt in doubts %}
|
| 165 |
+
<div class="doubt-item">
|
| 166 |
+
<strong>Hypothesis:</strong> {{ doubt.hypothesis }}<br>
|
| 167 |
+
<strong>Data Gap:</strong> {{ doubt.gap }}<br>
|
| 168 |
+
<strong>Recommended Action:</strong> {{ doubt.action }}
|
| 169 |
+
</div>
|
| 170 |
+
{% endfor %}
|
| 171 |
+
{% else %}
|
| 172 |
+
<p>No unexplained patterns identified in current dataset.</p>
|
| 173 |
+
{% endif %}
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
|
| 177 |
+
<div class="section">
|
| 178 |
+
<div class="section-header">Section 5 — Positive Contributions</div>
|
| 179 |
+
<div class="section-content">
|
| 180 |
+
{% if positive_contributions %}
|
| 181 |
+
{% for item in positive_contributions[:10] %}
|
| 182 |
+
<div class="positive-item">{{ item }}</div>
|
| 183 |
+
{% endfor %}
|
| 184 |
+
{% else %}
|
| 185 |
+
<p>Insufficient data to identify positive contributions.</p>
|
| 186 |
+
{% endif %}
|
| 187 |
+
</div>
|
| 188 |
+
</div>
|
| 189 |
+
|
| 190 |
+
<div class="section">
|
| 191 |
+
<div class="section-header">Section 6 — Timeline of Events</div>
|
| 192 |
+
<div class="section-content">
|
| 193 |
+
{% if timeline %}
|
| 194 |
+
{% for event in timeline[:20] %}
|
| 195 |
+
<div class="timeline-item">
|
| 196 |
+
<div class="timeline-date">{{ event.date }}</div>
|
| 197 |
+
<div class="timeline-event">
|
| 198 |
+
<strong>{{ event.event }}</strong> — {{ event.detail }}
|
| 199 |
+
<span class="source-chip">{{ event.source }}</span>
|
| 200 |
+
</div>
|
| 201 |
+
</div>
|
| 202 |
+
{% endfor %}
|
| 203 |
+
{% else %}
|
| 204 |
+
<p>No timeline events reconstructed from available data.</p>
|
| 205 |
+
{% endif %}
|
| 206 |
+
</div>
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<div class="section">
|
| 210 |
+
<div class="section-header">Section 7 — Evidence Locker</div>
|
| 211 |
+
<div class="section-content">
|
| 212 |
+
<p style="margin-bottom:8px; font-size:9px; color:#666;">
|
| 213 |
+
All {{ evidence_count }} primary source documents referenced in this report.
|
| 214 |
+
</p>
|
| 215 |
+
{% for evidence in evidence_locker %}
|
| 216 |
+
<div class="evidence-item">
|
| 217 |
+
<strong>{{ evidence.institution }}</strong> — {{ evidence.document }}
|
| 218 |
+
{% if evidence.url %}| <a href="{{ evidence.url }}">{{ evidence.url }}</a>{% endif %}
|
| 219 |
+
{% if evidence.date %}| Date: {{ evidence.date }}{% endif %}
|
| 220 |
+
</div>
|
| 221 |
+
{% endfor %}
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
|
| 225 |
+
<div class="section">
|
| 226 |
+
<div class="section-header">Section 8 — Methodology and Data Sources</div>
|
| 227 |
+
<div class="section-content">
|
| 228 |
+
<p>This dossier was generated by the BharatGraph Multi-Investigator AI Engine.
|
| 229 |
+
{{ investigator_count }} specialist investigators analysed this entity in parallel,
|
| 230 |
+
each from an independent analytical angle. Every finding is backed by a primary
|
| 231 |
+
source document. No claims are made without evidence.</p>
|
| 232 |
+
<p style="margin-top:8px;">Data sources used in this analysis include official Indian government
|
| 233 |
+
records (ECI affidavits, MCA filings, GeM contracts, CAG audit reports, PIB press
|
| 234 |
+
releases, parliamentary records) and international datasets (OpenSanctions,
|
| 235 |
+
ICIJ Offshore Leaks, Wikidata).</p>
|
| 236 |
+
<p style="margin-top:8px; font-weight:bold; color:#dc3545;">
|
| 237 |
+
LEGAL DISCLAIMER: This report is generated from publicly available official records.
|
| 238 |
+
It does not constitute a legal finding, accusation, or determination of guilt.
|
| 239 |
+
The subject of this report has the right to submit corrections at bharatgraph.in.
|
| 240 |
+
</p>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
|
| 244 |
+
<div class="page-footer">
|
| 245 |
+
BharatGraph Public Transparency Platform | Report Hash: {{ report_hash[:32] }}... | Generated: {{ generated_at }}
|
| 246 |
+
</div>
|
| 247 |
+
|
| 248 |
+
</body>
|
| 249 |
+
</html>
|