Aryan Mishra commited on
Commit
3c07ffe
·
1 Parent(s): 6b71335

Phase 4: HTMX Batch Analytics Page

Browse files
api/app/routes/pages.py CHANGED
@@ -143,3 +143,50 @@ async def predict_fragment(
143
  "partials/predict_result.html",
144
  {"request": request, "result": None, "error": str(e)}
145
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  "partials/predict_result.html",
144
  {"request": request, "result": None, "error": str(e)}
145
  )
146
+
147
+ # ── Phase 4 HTMX Endpoints ───────────────────────────────────────────────────
148
+
149
+ from fastapi import UploadFile, File
150
+ from api.app.routes.predict import predict_batch, get_batch_status
151
+
152
+ @router.post("/batch/fragment", response_class=HTMLResponse)
153
+ async def batch_fragment(
154
+ request: Request,
155
+ file: UploadFile = File(...),
156
+ db: Session = Depends(get_db)
157
+ ) -> HTMLResponse:
158
+ """
159
+ Phase 4: HTMX partial for starting a batch job.
160
+ """
161
+ try:
162
+ response = await predict_batch(file, db)
163
+ return templates.TemplateResponse(
164
+ "partials/batch_progress.html",
165
+ {"request": request, "job": response, "error": None}
166
+ )
167
+ except Exception as e:
168
+ return templates.TemplateResponse(
169
+ "partials/batch_progress.html",
170
+ {"request": request, "job": None, "error": str(e)}
171
+ )
172
+
173
+ @router.get("/batch/progress/{job_id}", response_class=HTMLResponse)
174
+ async def batch_progress_fragment(
175
+ request: Request,
176
+ job_id: str,
177
+ db: Session = Depends(get_db)
178
+ ) -> HTMLResponse:
179
+ """
180
+ Phase 4: HTMX partial for polling batch job status.
181
+ """
182
+ try:
183
+ job = await get_batch_status(job_id, db)
184
+ return templates.TemplateResponse(
185
+ "partials/batch_progress.html",
186
+ {"request": request, "job": job, "error": None}
187
+ )
188
+ except Exception as e:
189
+ return templates.TemplateResponse(
190
+ "partials/batch_progress.html",
191
+ {"request": request, "job": None, "error": str(e)}
192
+ )
api/app/routes/results.py CHANGED
@@ -19,3 +19,18 @@ async def get_info() -> Dict[str, str]:
19
  "supported_languages": "en, hi",
20
  "max_batch_size": os.getenv("MAX_BATCH_SIZE", "10000"),
21
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  "supported_languages": "en, hi",
20
  "max_batch_size": os.getenv("MAX_BATCH_SIZE", "10000"),
21
  }
22
+
23
+ from fastapi import HTTPException
24
+ from fastapi.responses import FileResponse
25
+ from pathlib import Path
26
+
27
+ @router.get("/download/{job_id}")
28
+ async def download_result(job_id: str):
29
+ file_path = Path(f"data/results/{job_id}.csv")
30
+ if not file_path.exists():
31
+ raise HTTPException(status_code=404, detail="Result file not found")
32
+ return FileResponse(
33
+ path=file_path,
34
+ filename=f"absa_results_{job_id}.csv",
35
+ media_type="text/csv"
36
+ )
api/app/templates/pages/batch.html CHANGED
@@ -28,34 +28,57 @@
28
  </p>
29
  </div>
30
 
31
- {# ── Upload zone placeholder ──────────────────────────────────────────────── #}
32
- <section class="card" aria-label="File upload">
33
- <div class="border-2 border-dashed border-white/[0.14] rounded-xl p-12
34
- flex flex-col items-center justify-center text-center">
35
- {{ ms_icon('cloud_upload', size=48, cls='text-[#c7c4d7] mb-4') }}
36
- <h2 class="text-headline-sm text-[#dae2fd] mb-2">
37
- Drag &amp; drop a CSV, or click to select
38
- </h2>
39
- <p class="text-body-md text-[#c7c4d7] max-w-sm">
40
- Must contain a <code class="font-mono text-[#c0c1ff] px-1">text</code> column.
41
- Maximum 10,000 rows. Files are deleted after analysis.
42
- </p>
43
- </div>
44
-
45
- {# Phase 2 notice #}
46
- <div class="mt-lg flex items-start gap-3 p-md rounded-lg bg-[#c0c1ff]/5 border border-[#c0c1ff]/20">
47
- {{ ms_icon('info', size=18, cls='text-[#c0c1ff] mt-0.5 flex-shrink-0') }}
48
- <div>
49
- <p class="text-body-sm text-[#c7c4d7]">
50
- File upload and batch processing are fully operational via
51
- <code class="font-mono text-[#c0c1ff] text-xs">POST /batch</code>.
52
- HTMX drag-and-drop UI and progress polling will be added in Phase 4.
53
- <a href="/docs#/Predict/predict_batch_batch_post"
54
- target="_blank"
55
- class="text-[#c0c1ff] hover:underline ml-1">Test the API →</a>
56
  </p>
57
  </div>
58
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  </section>
60
 
61
  {# ── Recent batches table placeholder ─────────────────────────────────────── #}
@@ -95,9 +118,6 @@
95
  </table>
96
  </div>
97
  </div>
98
- <p class="mt-2 font-mono text-label-sm text-[#c7c4d7]/50 text-center">
99
- Phase 4 will populate this table from the database.
100
- </p>
101
  </section>
102
 
103
  </div>
 
28
  </p>
29
  </div>
30
 
31
+ {# ── Upload zone ──────────────────────────────────────────────────────────── #}
32
+ <section aria-label="File upload">
33
+ <form hx-encoding="multipart/form-data"
34
+ hx-post="/batch/fragment"
35
+ hx-target="#batch-progress"
36
+ class="card group"
37
+ x-data="{ file: null, drag: false }">
38
+
39
+ <div x-on:dragover.prevent="drag = true"
40
+ x-on:dragleave.prevent="drag = false"
41
+ x-on:drop.prevent="drag = false; $refs.fileInput.files = $event.dataTransfer.files; file = $refs.fileInput.files[0]"
42
+ :class="drag ? 'border-[#c0c1ff]/60 bg-[#c0c1ff]/5' : 'border-white/[0.14] hover:border-[#c0c1ff]/40 hover:bg-[#c0c1ff]/[0.02]'"
43
+ class="border-2 border-dashed rounded-xl p-12 flex flex-col items-center justify-center text-center cursor-pointer transition-colors duration-200"
44
+ @click="$refs.fileInput.click()">
45
+ <input type="file" name="file" x-ref="fileInput" class="hidden" accept=".csv" @change="file = $event.target.files[0]">
46
+ {{ ms_icon('cloud_upload', size=48, cls='text-[#c7c4d7] mb-4') }}
47
+ <h2 class="text-headline-sm text-[#dae2fd] mb-2" x-text="drag ? 'Drop the CSV here…' : 'Drag &amp; drop a CSV, or click to select'"></h2>
48
+ <p class="text-body-md text-[#c7c4d7] max-w-sm">
49
+ Must contain a <code class="font-mono text-[#c0c1ff] px-1">text</code> column.
50
+ Maximum 10,000 rows. Files are deleted after analysis.
 
 
 
 
 
51
  </p>
52
  </div>
53
+
54
+ <!-- Selected file row -->
55
+ <template x-if="file">
56
+ <div class="mt-lg flex items-center justify-between p-md rounded-lg bg-[#222a3d] border border-white/[0.08] animate-slide-in">
57
+ <div class="flex items-center gap-3">
58
+ {{ ms_icon('description', size=20, cls='text-[#c0c1ff]') }}
59
+ <div>
60
+ <p class="text-body-md text-[#dae2fd] font-medium" x-text="file.name"></p>
61
+ <p class="font-mono text-label-sm text-[#c7c4d7]" x-text="(file.size / 1024 / 1024).toFixed(2) + ' MB'"></p>
62
+ </div>
63
+ </div>
64
+ <div class="flex gap-2">
65
+ <button type="button" @click.stop="file = null; $refs.fileInput.value = ''" class="px-3 py-1.5 text-body-sm text-[#c7c4d7] border border-white/[0.12] rounded-lg hover:bg-white/[0.05] hover:text-[#dae2fd] transition-colors">
66
+ Remove
67
+ </button>
68
+ <button type="submit" class="btn-primary text-body-sm relative group-[.htmx-request]:pointer-events-none">
69
+ <div class="flex items-center gap-2 group-[.htmx-request]:hidden">
70
+ {{ ms_icon('rocket_launch', size=16) }} Process File
71
+ </div>
72
+ <div class="htmx-indicator items-center gap-2">
73
+ <span class="material-symbols-outlined animate-spin" style="font-size:16px;">progress_activity</span> Processing
74
+ </div>
75
+ </button>
76
+ </div>
77
+ </div>
78
+ </template>
79
+ </form>
80
+
81
+ <div id="batch-progress" class="mt-lg"></div>
82
  </section>
83
 
84
  {# ── Recent batches table placeholder ─────────────────────────────────────── #}
 
118
  </table>
119
  </div>
120
  </div>
 
 
 
121
  </section>
122
 
123
  </div>
api/app/templates/partials/batch_progress.html ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% from "macros/ui.html" import ms_icon %}
2
+
3
+ <div class="card-low animate-fade-in p-6" id="batch-progress-container">
4
+ {% if error %}
5
+ <div class="flex flex-col items-center justify-center gap-4 py-8 text-[#ffb4ab]">
6
+ {{ ms_icon('error', size=48, cls='opacity-80') }}
7
+ <p class="text-sm font-medium">{{ error }}</p>
8
+ </div>
9
+ {% else %}
10
+ <div class="flex items-center justify-between mb-4">
11
+ <div>
12
+ <h4 class="text-body-lg text-[#dae2fd] font-medium">Batch Job: <span class="font-mono text-sm">{{ job.job_id }}</span></h4>
13
+ <p class="text-body-sm text-[#c7c4d7]/70 mt-1">Status: <span class="capitalize">{{ job.status }}</span></p>
14
+ </div>
15
+ {% if job.status == 'completed' %}
16
+ <a href="/results/download/{{ job.job_id }}" class="btn-primary" download>
17
+ {{ ms_icon('download', size=18) }}
18
+ Download Results
19
+ </a>
20
+ {% elif job.status == 'failed' %}
21
+ <span class="text-[#ffb4ab] font-medium flex items-center gap-1">{{ ms_icon('error', size=16) }} Failed</span>
22
+ {% else %}
23
+ <div class="flex items-center gap-2 text-[#c0c1ff]">
24
+ <span class="material-symbols-outlined animate-spin" style="font-size:16px;">autorenew</span>
25
+ <span class="text-sm font-medium">Processing...</span>
26
+ </div>
27
+ {% endif %}
28
+ </div>
29
+
30
+ <div class="w-full bg-[#1b2234] rounded-full h-3 mb-2 overflow-hidden border border-white/[0.04]">
31
+ {% set percent = (job.processed / job.total_reviews * 100) if job.total_reviews > 0 else 0 %}
32
+ <div class="bg-[#c0c1ff] h-3 rounded-full transition-all duration-500 ease-out" style="width: {{ percent }}%"></div>
33
+ </div>
34
+
35
+ <div class="flex justify-between text-label-sm font-mono text-[#c7c4d7]/70">
36
+ <span>{{ job.processed }} processed</span>
37
+ <span>{{ job.total_reviews }} total</span>
38
+ </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
+ {% endif %}
43
+ {% endif %}
44
+ </div>
tests/web/test_pages.py CHANGED
@@ -225,3 +225,48 @@ class TestPredictFragment:
225
  method == "post" for method in paths.get("/batch", {})
226
  ), "GET /batch should not appear in OpenAPI schema"
227
  assert "/monitor" not in paths, "GET /monitor should not appear in OpenAPI schema"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  method == "post" for method in paths.get("/batch", {})
226
  ), "GET /batch should not appear in OpenAPI schema"
227
  assert "/monitor" not in paths, "GET /monitor should not appear in OpenAPI schema"
228
+
229
+ class TestBatchFragments:
230
+ """Verify Phase 4 Batch Analytics HTMX endpoints."""
231
+
232
+ def test_batch_fragment_upload(self):
233
+ with _html_client() as client:
234
+ files = {"file": ("test.csv", b"text\nThis is great\nThis is bad", "text/csv")}
235
+ response = client.post("/batch/fragment", files=files)
236
+
237
+ assert response.status_code == 200
238
+ assert "text/html" in response.headers["content-type"]
239
+ assert "Batch Job:" in response.text
240
+ assert "queued" in response.text.lower() or "processing" in response.text.lower() or "completed" in response.text.lower()
241
+
242
+ def test_batch_fragment_invalid_file(self):
243
+ with _html_client() as client:
244
+ files = {"file": ("test.txt", b"text\nThis is great", "text/plain")}
245
+ response = client.post("/batch/fragment", files=files)
246
+
247
+ assert response.status_code == 200
248
+ assert "text/html" in response.headers["content-type"]
249
+ assert "Only CSV files are allowed" in response.text or "error" in response.text.lower()
250
+
251
+ def test_batch_progress_polling(self):
252
+ # 1. upload file to get job_id
253
+ with _html_client() as client:
254
+ files = {"file": ("test.csv", b"text\nHello", "text/csv")}
255
+ response1 = client.post("/batch/fragment", files=files)
256
+ import re
257
+ match = re.search(r'<span class="font-mono text-sm">([a-f0-9-]+)</span>', response1.text)
258
+ assert match, "Could not find job_id in HTML"
259
+ job_id = match.group(1)
260
+
261
+ # 2. poll progress
262
+ response2 = client.get(f"/batch/progress/{job_id}")
263
+ assert response2.status_code == 200
264
+ assert "text/html" in response2.headers["content-type"]
265
+ assert job_id in response2.text
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