abinazebinoy commited on
Commit
4435778
·
1 Parent(s): 5ac9ba3

feat(phase-33): add api/routes/timeline.py with 2 endpoints

Browse files

GET /timeline/{entity_id} -- all time-stamped events for an entity
sorted newest-first (feeds EvidencePanel)
GET /timeline/{entity_id}/by-year -- same events bucketed by year

The EvidencePanel timeline tab calls /profile/{entity_id} and receives a
ProfileResponse with no events field -- the timeline always shows empty.
This dedicated /timeline endpoint queries all connected nodes that have
any date field (order_date, date, filing_date, scraped_at, year) and
returns structured events with category, title, amount, source.

Categories: contract, audit, enforcement, financial, legal, political,
electoral, regulatory, vigilance, corporate, other.

Files changed (1) hide show
  1. api/routes/timeline.py +163 -0
api/routes/timeline.py ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BharatGraph - Phase 33: Timeline API
3
+ GET /timeline/{entity_id} -- chronological event feed for an entity
4
+ GET /timeline/{entity_id}/by-year -- same events bucketed by year
5
+
6
+ The EvidencePanel timeline tab calls /profile/{entity_id} and gets no events.
7
+ This route queries the graph directly for time-stamped activity across all
8
+ node types connected to the entity.
9
+
10
+ Pure ASCII.
11
+ """
12
+ import os
13
+ import sys
14
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
15
+
16
+ from datetime import datetime
17
+ from typing import Optional
18
+ from fastapi import APIRouter, Depends, Query
19
+ from loguru import logger
20
+
21
+ from api.dependencies import get_db
22
+
23
+ router = APIRouter(prefix="/timeline", tags=["Timeline"])
24
+
25
+
26
+ def _label_to_category(label: str) -> str:
27
+ """Map Neo4j node label to a timeline event category."""
28
+ mapping = {
29
+ "Contract": "contract",
30
+ "AuditReport": "audit",
31
+ "EnforcementAction":"enforcement",
32
+ "ElectoralBond": "financial",
33
+ "CourtCase": "legal",
34
+ "PressRelease": "political",
35
+ "Affidavit": "electoral",
36
+ "RegulatoryOrder": "regulatory",
37
+ "VigilanceCircular":"vigilance",
38
+ "Company": "corporate",
39
+ "Ministry": "corporate",
40
+ "Tender": "contract",
41
+ "InsolvencyOrder": "legal",
42
+ "NGO": "corporate",
43
+ "Politician": "political",
44
+ }
45
+ return mapping.get(label, "other")
46
+
47
+
48
+ def _extract_date(props: dict) -> Optional[str]:
49
+ """Find the most reliable date field from a node properties dict."""
50
+ for field in ("order_date", "date", "filing_date", "registered_at",
51
+ "case_date", "issue_date", "scraped_at", "year"):
52
+ val = props.get(field)
53
+ if val:
54
+ return str(val)
55
+ return None
56
+
57
+
58
+ @router.get("/{entity_id}")
59
+ def entity_timeline(
60
+ entity_id: str,
61
+ limit: int = Query(100, ge=1, le=500),
62
+ category: Optional[str] = Query(None,
63
+ description="Filter: contract, audit, legal, financial, ..."),
64
+ driver=Depends(get_db),
65
+ ):
66
+ """
67
+ Return all time-stamped events connected to an entity, sorted
68
+ newest first. This feeds the EvidencePanel timeline tab.
69
+
70
+ Categories: contract, audit, enforcement, financial, legal,
71
+ political, electoral, regulatory, vigilance, corporate, other.
72
+
73
+ Example:
74
+ GET /timeline/pol_abc123?category=contract&limit=20
75
+ """
76
+ logger.info(f"[Timeline] entity={entity_id[:8]} limit={limit}")
77
+ events = []
78
+
79
+ with driver.session() as session:
80
+ # Fetch all connected nodes that have any date field
81
+ rows = session.run(
82
+ """
83
+ MATCH (e {id: })-[r]-(n)
84
+ WHERE n.scraped_at IS NOT NULL
85
+ OR n.order_date IS NOT NULL
86
+ OR n.date IS NOT NULL
87
+ OR n.filing_date IS NOT NULL
88
+ OR n.year IS NOT NULL
89
+ RETURN labels(n)[0] AS node_label,
90
+ type(r) AS rel_type,
91
+ properties(n) AS props,
92
+ n.id AS nid
93
+ LIMIT
94
+ """,
95
+ id=entity_id, limit=limit * 3 # over-fetch so filtering doesn't starve
96
+ ).data()
97
+
98
+ for row in rows:
99
+ label = row.get("node_label", "Unknown")
100
+ cat = _label_to_category(label)
101
+ if category and cat != category.lower():
102
+ continue
103
+ props = row.get("props") or {}
104
+ date_str = _extract_date(props)
105
+ events.append({
106
+ "date": date_str,
107
+ "category": cat,
108
+ "label": label,
109
+ "rel_type": row.get("rel_type", ""),
110
+ "node_id": row.get("nid", ""),
111
+ "title": props.get("title", props.get("name",
112
+ props.get("order_id", row.get("nid", ""))))[:120],
113
+ "detail": props.get("summary", props.get("description",
114
+ props.get("item_desc", "")))[:300],
115
+ "amount_crore": props.get("amount_crore",
116
+ props.get("total_assets_crore")),
117
+ "source": props.get("source", ""),
118
+ })
119
+
120
+ # Sort by date descending (None dates go to the end)
121
+ events.sort(
122
+ key=lambda x: x["date"] or "0000-00-00",
123
+ reverse=True,
124
+ )
125
+ events = events[:limit]
126
+
127
+ return {
128
+ "entity_id": entity_id,
129
+ "total_events": len(events),
130
+ "category": category,
131
+ "events": events,
132
+ "generated_at": datetime.now().isoformat(),
133
+ }
134
+
135
+
136
+ @router.get("/{entity_id}/by-year")
137
+ def entity_timeline_by_year(
138
+ entity_id: str,
139
+ driver=Depends(get_db),
140
+ ):
141
+ """
142
+ Same as /{entity_id} but events are bucketed by year.
143
+ Useful for rendering a bar chart or year-selector UI.
144
+ """
145
+ result = entity_timeline(entity_id, limit=500, category=None, driver=driver)
146
+ by_year = {}
147
+ for ev in result["events"]:
148
+ date = ev.get("date") or ""
149
+ year = date[:4] if len(date) >= 4 and date[:4].isdigit() else "unknown"
150
+ by_year.setdefault(year, []).append(ev)
151
+
152
+ # Sort years descending
153
+ sorted_years = sorted(
154
+ [k for k in by_year if k != "unknown"],
155
+ reverse=True
156
+ ) + (["unknown"] if "unknown" in by_year else [])
157
+
158
+ return {
159
+ "entity_id": entity_id,
160
+ "total_years": len(by_year),
161
+ "by_year": {yr: by_year[yr] for yr in sorted_years},
162
+ "generated_at": datetime.now().isoformat(),
163
+ }