Aryan Mishra commited on
Commit
64a410d
·
1 Parent(s): 014f36c

Phase 5.6: Restore Chart Feature Parity

Browse files
api/app/routes/pages.py CHANGED
@@ -213,3 +213,72 @@ async def monitor_health_fragment(request: Request) -> HTMLResponse:
213
  "partials/monitor_health.html",
214
  {"request": request, "health": None, "error": str(e)}
215
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  "partials/monitor_health.html",
214
  {"request": request, "health": None, "error": str(e)}
215
  )
216
+
217
+ import pandas as pd
218
+ from pathlib import Path
219
+ import json
220
+
221
+ @router.get("/batch/charts/{job_id}", response_class=HTMLResponse)
222
+ async def batch_charts_fragment(request: Request, job_id: str) -> HTMLResponse:
223
+ """
224
+ Phase 5.6: HTMX partial for rendering charts.
225
+ Parses the generated CSV and passes JSON directly to the template for Chart.js.
226
+ """
227
+ try:
228
+ file_path = Path(f"data/results/{job_id}.csv")
229
+ if not file_path.exists():
230
+ return templates.TemplateResponse("partials/batch_charts.html", {"request": request, "error": "CSV not found"})
231
+
232
+ df = pd.read_csv(file_path)
233
+
234
+ lang_pie = []
235
+ if "language" in df.columns:
236
+ counts = df["language"].value_counts().to_dict()
237
+ lang_pie = [{"name": str(k), "value": int(v)} for k, v in counts.items()]
238
+
239
+ aspect_heat = []
240
+ if "aspect" in df.columns and "sentiment" in df.columns:
241
+ # Group by aspect and sentiment
242
+ grouped = df.groupby(["aspect", "sentiment"]).size().unstack(fill_value=0)
243
+ for aspect, row in grouped.iterrows():
244
+ if pd.isna(aspect) or not aspect:
245
+ continue
246
+ aspect_heat.append({
247
+ "aspect": str(aspect),
248
+ "positive": int(row.get("positive", 0)),
249
+ "negative": int(row.get("negative", 0)),
250
+ "neutral": int(row.get("neutral", 0)),
251
+ "conflict": int(row.get("conflict", 0))
252
+ })
253
+
254
+ sent_line = []
255
+ if "sentiment" in df.columns:
256
+ df_sent = df[df["sentiment"].notna()]
257
+ n = len(df_sent)
258
+ # Create 7 chunks for the line chart
259
+ chunk_size = max(1, n // 7) if n > 0 else 1
260
+ for i in range(7):
261
+ chunk = df_sent.iloc[i*chunk_size : (i+1)*chunk_size]
262
+ if chunk.empty:
263
+ break
264
+ counts = chunk["sentiment"].value_counts().to_dict()
265
+ sent_line.append({
266
+ "name": f"Batch {i+1}",
267
+ "positive": int(counts.get("positive", 0)),
268
+ "negative": int(counts.get("negative", 0)),
269
+ "neutral": int(counts.get("neutral", 0)),
270
+ "conflict": int(counts.get("conflict", 0))
271
+ })
272
+
273
+ return templates.TemplateResponse(
274
+ "partials/batch_charts.html",
275
+ {
276
+ "request": request,
277
+ "language_pie": json.dumps(lang_pie),
278
+ "aspect_heatmap": json.dumps(aspect_heat),
279
+ "sentiment_chart": json.dumps(sent_line),
280
+ "error": None
281
+ }
282
+ )
283
+ except Exception as e:
284
+ return templates.TemplateResponse("partials/batch_charts.html", {"request": request, "error": str(e)})
api/app/templates/partials/batch_charts.html ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% if error %}
2
+ <div class="card bg-error/10 border border-error/20 p-md rounded-lg text-center">
3
+ <p class="text-error font-medium">Failed to load charts: {{ error }}</p>
4
+ </div>
5
+ {% else %}
6
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
7
+ <div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-xl animate-fade-in" id="batch-charts-container">
8
+
9
+ {# Aspect Heatmap (Stacked Bar) #}
10
+ <div class="xl:col-span-2 bg-[#222a3d] rounded-xl shadow-sm border border-white/[0.08] p-6 h-96">
11
+ <h3 class="text-sm font-semibold text-[#dae2fd] mb-4">Top Aspects by Sentiment</h3>
12
+ <div class="relative w-full h-[300px]">
13
+ <canvas id="aspectChart"></canvas>
14
+ </div>
15
+ </div>
16
+
17
+ {# Language Pie #}
18
+ <div class="bg-[#222a3d] rounded-xl shadow-sm border border-white/[0.08] p-6 h-96">
19
+ <h3 class="text-sm font-semibold text-[#dae2fd] mb-4">Language Distribution</h3>
20
+ <div class="relative w-full h-[300px]">
21
+ <canvas id="languageChart"></canvas>
22
+ </div>
23
+ </div>
24
+
25
+ {# Sentiment Chart (Line) #}
26
+ <div class="lg:col-span-2 xl:col-span-3 bg-[#222a3d] rounded-xl shadow-sm border border-white/[0.08] p-6 h-96">
27
+ <h3 class="text-sm font-semibold text-[#dae2fd] mb-4">Sentiment Over Time (Row Chunks)</h3>
28
+ <div class="relative w-full h-[300px]">
29
+ <canvas id="sentimentChart"></canvas>
30
+ </div>
31
+ </div>
32
+
33
+ </div>
34
+
35
+ <script>
36
+ (function() {
37
+ // Shared styling for dark mode
38
+ Chart.defaults.color = '#c7c4d7';
39
+ Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.08)';
40
+
41
+ // Data passed from backend
42
+ const rawAspect = {{ aspect_heatmap | safe }};
43
+ const rawLang = {{ language_pie | safe }};
44
+ const rawSent = {{ sentiment_chart | safe }};
45
+
46
+ // 1. Aspect Stacked Bar Chart (Horizontal)
47
+ const aspectCtx = document.getElementById('aspectChart');
48
+ if (aspectCtx && rawAspect.length > 0) {
49
+ new Chart(aspectCtx, {
50
+ type: 'bar',
51
+ data: {
52
+ labels: rawAspect.map(d => d.aspect),
53
+ datasets: [
54
+ { label: 'Positive', data: rawAspect.map(d => d.positive), backgroundColor: '#10B981' },
55
+ { label: 'Negative', data: rawAspect.map(d => d.negative), backgroundColor: '#EF4444' },
56
+ { label: 'Neutral', data: rawAspect.map(d => d.neutral), backgroundColor: '#6B7280' },
57
+ { label: 'Conflict', data: rawAspect.map(d => d.conflict), backgroundColor: '#F59E0B' }
58
+ ]
59
+ },
60
+ options: {
61
+ indexAxis: 'y',
62
+ responsive: true,
63
+ maintainAspectRatio: false,
64
+ scales: { x: { stacked: true }, y: { stacked: true } }
65
+ }
66
+ });
67
+ }
68
+
69
+ // 2. Language Pie Chart
70
+ const langCtx = document.getElementById('languageChart');
71
+ if (langCtx && rawLang.length > 0) {
72
+ new Chart(langCtx, {
73
+ type: 'pie',
74
+ data: {
75
+ labels: rawLang.map(d => d.name),
76
+ datasets: [{
77
+ data: rawLang.map(d => d.value),
78
+ backgroundColor: ['#3B82F6', '#F97316', '#10B981', '#8B5CF6'],
79
+ borderWidth: 0
80
+ }]
81
+ },
82
+ options: {
83
+ responsive: true,
84
+ maintainAspectRatio: false,
85
+ plugins: {
86
+ legend: { position: 'bottom' }
87
+ }
88
+ }
89
+ });
90
+ }
91
+
92
+ // 3. Sentiment Line Chart
93
+ const sentCtx = document.getElementById('sentimentChart');
94
+ if (sentCtx && rawSent.length > 0) {
95
+ new Chart(sentCtx, {
96
+ type: 'line',
97
+ data: {
98
+ labels: rawSent.map(d => d.name),
99
+ datasets: [
100
+ { label: 'Positive', data: rawSent.map(d => d.positive), borderColor: '#10B981', backgroundColor: '#10B981', tension: 0.3 },
101
+ { label: 'Negative', data: rawSent.map(d => d.negative), borderColor: '#EF4444', backgroundColor: '#EF4444', tension: 0.3 },
102
+ { label: 'Neutral', data: rawSent.map(d => d.neutral), borderColor: '#6B7280', backgroundColor: '#6B7280', tension: 0.3 },
103
+ { label: 'Conflict', data: rawSent.map(d => d.conflict), borderColor: '#F59E0B', backgroundColor: '#F59E0B', tension: 0.3 }
104
+ ]
105
+ },
106
+ options: {
107
+ responsive: true,
108
+ maintainAspectRatio: false
109
+ }
110
+ });
111
+ }
112
+ })();
113
+ </script>
114
+ {% endif %}
api/app/templates/partials/batch_progress.html CHANGED
@@ -39,6 +39,13 @@
39
 
40
  {% if job.status in ['queued', 'processing'] %}
41
  <div hx-get="/batch/progress/{{ job.job_id }}" hx-trigger="every 2s" hx-swap="outerHTML" hx-target="#batch-progress-container"></div>
 
 
 
 
 
 
 
42
  {% endif %}
43
  {% endif %}
44
  </div>
 
39
 
40
  {% if job.status in ['queued', 'processing'] %}
41
  <div hx-get="/batch/progress/{{ job.job_id }}" hx-trigger="every 2s" hx-swap="outerHTML" hx-target="#batch-progress-container"></div>
42
+ {% elif job.status == 'completed' %}
43
+ <div class="mt-8" hx-get="/batch/charts/{{ job.job_id }}" hx-trigger="load" hx-swap="innerHTML">
44
+ <div class="flex items-center justify-center gap-2 text-[#c7c4d7]/70 font-mono text-sm py-8 animate-pulse">
45
+ <span class="material-symbols-outlined animate-spin" style="font-size:18px;">analytics</span>
46
+ Generating charts...
47
+ </div>
48
+ </div>
49
  {% endif %}
50
  {% endif %}
51
  </div>
tests/web/test_pages.py CHANGED
@@ -266,11 +266,44 @@ class TestBatchFragments:
266
 
267
  def test_download_endpoint_exists(self):
268
  with _html_client() as client:
269
- # We don't have a guaranteed completed job to download, but we can verify
270
- # 404 is returned instead of 405 Method Not Allowed, meaning it exists.
271
  response = client.get("/results/download/fake-job-id")
272
  assert response.status_code == 404
273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  class TestMonitorFragments:
275
  """Verify Phase 5 System Monitor HTMX endpoints."""
276
 
 
266
 
267
  def test_download_endpoint_exists(self):
268
  with _html_client() as client:
 
 
269
  response = client.get("/results/download/fake-job-id")
270
  assert response.status_code == 404
271
 
272
+ def test_batch_charts_endpoint_handles_missing_file(self):
273
+ with _html_client() as client:
274
+ response = client.get("/batch/charts/fake-job-id")
275
+ assert response.status_code == 200
276
+ assert "text/html" in response.headers["content-type"]
277
+ assert "CSV not found" in response.text
278
+
279
+ def test_batch_charts_endpoint_valid_file(self, tmp_path):
280
+ import pandas as pd
281
+ from pathlib import Path
282
+
283
+ # Create a mock CSV for a fake job
284
+ job_id = "test-job-charts"
285
+ test_file = Path(f"data/results/{job_id}.csv")
286
+ test_file.parent.mkdir(parents=True, exist_ok=True)
287
+
288
+ df = pd.DataFrame({
289
+ "text": ["hello", "world"],
290
+ "language": ["en", "hi"],
291
+ "aspect": ["food", "service"],
292
+ "sentiment": ["positive", "negative"]
293
+ })
294
+ df.to_csv(test_file, index=False)
295
+
296
+ try:
297
+ with _html_client() as client:
298
+ response = client.get(f"/batch/charts/{job_id}")
299
+ assert response.status_code == 200
300
+ assert "text/html" in response.headers["content-type"]
301
+ assert "chart.js" in response.text.lower()
302
+ assert "languageChart" in response.text
303
+ finally:
304
+ if test_file.exists():
305
+ test_file.unlink()
306
+
307
  class TestMonitorFragments:
308
  """Verify Phase 5 System Monitor HTMX endpoints."""
309