Spaces:
Runtime error
Runtime error
Aryan Mishra commited on
Commit ·
3c07ffe
1
Parent(s): 6b71335
Phase 4: HTMX Batch Analytics Page
Browse files- api/app/routes/pages.py +47 -0
- api/app/routes/results.py +15 -0
- api/app/templates/pages/batch.html +49 -29
- api/app/templates/partials/batch_progress.html +44 -0
- tests/web/test_pages.py +45 -0
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
|
| 32 |
-
<section
|
| 33 |
-
<
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
<
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 & 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
|