abinazebinoy commited on
Commit
cb19a24
·
1 Parent(s): 17b1c9a

feat(ai): complete — PDF dossier generator with integrity hash

Browse files

Confirmed 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 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.4.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, tags=["Search"])
45
- app.include_router(profile.router, tags=["Profile"])
46
- app.include_router(graph.router, tags=["Graph"])
47
- app.include_router(risk.router, tags=["Risk"])
 
 
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,29 +59,26 @@ def health_check():
59
  return HealthResponse(
60
  status="ok" if connected else "degraded",
61
  neo4j_connected=connected,
62
- version="0.4.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
-
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>