Aryan Mishra commited on
Commit
d0a5e7c
·
1 Parent(s): d9530b5

Phase 2: Jinja2 infrastructure

Browse files
api/app/core/templates.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Centralized Jinja2Templates instance.
3
+
4
+ Kept in api/app/core/ so every page-rendering router imports from one place,
5
+ avoiding multiple conflicting Template objects pointing at the same directory.
6
+
7
+ WHY THIS FILE EXISTS
8
+ --------------------
9
+ FastAPI's Jinja2Templates must be initialised with a directory path.
10
+ Centralising it here means that when Phase 3-5 routers add fragment endpoints
11
+ they import `templates` from here — no duplication, no divergence.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from fastapi.templating import Jinja2Templates
18
+
19
+ # Resolve relative to this file:
20
+ # api/app/core/templates.py → api/app/templates/
21
+ _TEMPLATE_DIR: Path = Path(__file__).parent.parent / "templates"
22
+
23
+ templates = Jinja2Templates(directory=str(_TEMPLATE_DIR))
api/app/main.py CHANGED
@@ -2,10 +2,13 @@ from fastapi import FastAPI
2
  from contextlib import asynccontextmanager
3
  from dotenv import load_dotenv
4
  from fastapi.middleware.cors import CORSMiddleware
 
 
5
 
6
  load_dotenv()
7
 
8
  from api.app.routes import predict, results # noqa: E402
 
9
  from api.app.middleware.metrics import instrumentator # noqa: E402
10
  from api.app.services.absa_pipeline import pipeline # noqa: E402
11
  from api.app.schemas.db_models import Base # noqa: E402
@@ -46,3 +49,23 @@ app.include_router(predict.router, tags=["Predict"])
46
  app.include_router(results.router, tags=["System"])
47
 
48
  instrumentator.instrument(app).expose(app, endpoint="/metrics")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from contextlib import asynccontextmanager
3
  from dotenv import load_dotenv
4
  from fastapi.middleware.cors import CORSMiddleware
5
+ from fastapi.staticfiles import StaticFiles # Phase 2: serves api/app/static/
6
+ from pathlib import Path # Phase 2: resolve static directory path
7
 
8
  load_dotenv()
9
 
10
  from api.app.routes import predict, results # noqa: E402
11
+ from api.app.routes import pages # noqa: E402 Phase 2: Jinja2 page routes
12
  from api.app.middleware.metrics import instrumentator # noqa: E402
13
  from api.app.services.absa_pipeline import pipeline # noqa: E402
14
  from api.app.schemas.db_models import Base # noqa: E402
 
49
  app.include_router(results.router, tags=["System"])
50
 
51
  instrumentator.instrument(app).expose(app, endpoint="/metrics")
52
+
53
+ # ── Phase 2: Jinja2 / HTMX frontend ──────────────────────────────────────────
54
+ #
55
+ # WHY pages router is included AFTER instrumentator:
56
+ # The instrumentator middleware wraps the entire ASGI app; order of
57
+ # router inclusion does not affect which routes get instrumented. Including
58
+ # the pages router last is simply a readability convention — API routes first.
59
+ #
60
+ # WHY StaticFiles is mounted AFTER instrumentator:
61
+ # app.mount() creates a sub-application. Mounting after the instrumentator
62
+ # call means the Prometheus middleware still wraps /static/* requests, but
63
+ # since static files are not hot paths for an ML tool this is acceptable.
64
+ # (Phase 7 cleanup will add /static to excluded_handlers in metrics.py.)
65
+
66
+ app.include_router(pages.router) # include_in_schema=False is set on the router itself
67
+
68
+ # Resolve path relative to this file so it works regardless of CWD.
69
+ _STATIC_DIR = Path(__file__).parent / "static"
70
+ _STATIC_DIR.mkdir(parents=True, exist_ok=True) # idempotent safety guard
71
+ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
api/app/routes/pages.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Page routes — Jinja2/HTMX web frontend.
3
+
4
+ WHY THIS FILE EXISTS
5
+ --------------------
6
+ All HTML-serving GET routes live here, completely separate from the JSON REST
7
+ routes in routes/predict.py and routes/results.py. This boundary means:
8
+
9
+ • REST routes never return HTML accidentally.
10
+ • Page routes never appear in the OpenAPI schema (include_in_schema=False).
11
+ • Future phases add HTMX fragment endpoints alongside these page routes
12
+ without touching any existing API code.
13
+
14
+ WHAT THIS FILE DOES (Phase 2)
15
+ ------------------------------
16
+ Registers four GET routes that render placeholder Jinja2 templates:
17
+ GET / → redirect to /predict
18
+ GET /predict → pages/predict.html
19
+ GET /batch → pages/batch.html
20
+ GET /monitor → pages/monitor.html
21
+
22
+ No business logic. No inference. No database queries.
23
+ The routes exist only to prove the template rendering infrastructure works.
24
+
25
+ HTMX fragment endpoints (POST /predict/fragment, GET /batch/progress/{id},
26
+ GET /monitor/health-partial) will be added in Phases 3-5.
27
+ """
28
+ from __future__ import annotations
29
+
30
+ from fastapi import APIRouter, Request
31
+ from fastapi.responses import HTMLResponse
32
+
33
+ from api.app.core.templates import templates
34
+
35
+ # include_in_schema=False keeps these HTML routes out of the OpenAPI / Swagger UI.
36
+ router = APIRouter(include_in_schema=False)
37
+
38
+ # ── Navigation structure ───────────────────────────────────────────────────────
39
+ # Mirrors the NAV constant in the React Sidebar.jsx so sidebar rendering is
40
+ # driven from a single Python list rather than hard-coded in every template.
41
+ _NAV_ITEMS: list[dict[str, str]] = [
42
+ {"path": "/predict", "icon": "psychology", "label": "Predictor"},
43
+ {"path": "/batch", "icon": "cloud_upload", "label": "Batch Analytics"},
44
+ {"path": "/monitor", "icon": "monitoring", "label": "System Health"},
45
+ ]
46
+
47
+
48
+ def _base_ctx(request: Request, page_title: str, **extra: object) -> dict:
49
+ """
50
+ Build the Jinja2 template context that base.html expects.
51
+
52
+ Every page renderer calls this so the sidebar and header always receive
53
+ the nav items and the current path (for active-link highlighting).
54
+ """
55
+ return {
56
+ "request": request, # required by Jinja2Templates
57
+ "page_title": page_title,
58
+ "nav_items": _NAV_ITEMS,
59
+ "current_path": request.url.path,
60
+ **extra,
61
+ }
62
+
63
+
64
+ # ── Routes ─────────────────────────────────────────────────────────────────────
65
+
66
+ @router.get("/", response_class=HTMLResponse)
67
+ async def index(request: Request) -> HTMLResponse:
68
+ """Root → serve the Predict page (same behaviour as React's Navigate redirect)."""
69
+ return templates.TemplateResponse(
70
+ "pages/predict.html",
71
+ _base_ctx(request, "Live Predictor"),
72
+ )
73
+
74
+
75
+ @router.get("/predict", response_class=HTMLResponse)
76
+ async def predict_page(request: Request) -> HTMLResponse:
77
+ """
78
+ Jinja2 Live Predictor page.
79
+ Phase 2: renders the layout shell with a placeholder content block.
80
+ Phase 3: the content block will contain the HTMX predict form + result panel.
81
+ """
82
+ return templates.TemplateResponse(
83
+ "pages/predict.html",
84
+ _base_ctx(request, "Live Predictor"),
85
+ )
86
+
87
+
88
+ @router.get("/batch", response_class=HTMLResponse)
89
+ async def batch_page(request: Request) -> HTMLResponse:
90
+ """
91
+ Jinja2 Batch Analytics page.
92
+ Phase 2: placeholder.
93
+ Phase 4: file upload form + progress polling.
94
+ """
95
+ return templates.TemplateResponse(
96
+ "pages/batch.html",
97
+ _base_ctx(request, "Batch Analytics"),
98
+ )
99
+
100
+
101
+ @router.get("/monitor", response_class=HTMLResponse)
102
+ async def monitor_page(request: Request) -> HTMLResponse:
103
+ """
104
+ Jinja2 System Monitor page.
105
+ Phase 2: placeholder.
106
+ Phase 5: live health status + performance metrics.
107
+ """
108
+ return templates.TemplateResponse(
109
+ "pages/monitor.html",
110
+ _base_ctx(request, "System Monitor"),
111
+ )
api/app/static/css/app.css ADDED
@@ -0,0 +1,497 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ * SentimentAI — Application Design System
3
+ *
4
+ * WHY THIS FILE EXISTS
5
+ * --------------------
6
+ * The React dashboard used Tailwind's @apply directive to define component
7
+ * classes (badge-positive, card, btn-primary, etc.) inside index.css. Those
8
+ * @apply rules require a compiled Tailwind build step which we are eliminating.
9
+ *
10
+ * This file replaces index.css + the @apply rules with equivalent plain CSS.
11
+ * Tailwind utility classes (bg-*, text-*, flex, etc.) are still available via
12
+ * the CDN Play CDN loaded in base.html — this file only contains component-level
13
+ * classes that the CDN cannot generate from the HTML scan.
14
+ *
15
+ * Design token values are taken verbatim from dashboard/tailwind.config.js.
16
+ * Do not change token values here without updating the Tailwind CDN config in
17
+ * base.html — they must stay in sync.
18
+ *
19
+ * SECTIONS
20
+ * --------
21
+ * 1. Base / Reset
22
+ * 2. Material Symbols Outlined icon font
23
+ * 3. Scrollbar
24
+ * 4. Focus ring
25
+ * 5. Layout helpers (glass-panel, sidebar, overlay)
26
+ * 6. Navigation (nav-item, nav-item-active)
27
+ * 7. Badges (badge-positive, -negative, -neutral, -processing, -error)
28
+ * 8. Highlights (highlight-positive, -negative, -neutral)
29
+ * 9. Cards (card, card-low, stat-card)
30
+ * 10. Form controls (input-base, textarea reset)
31
+ * 11. Button (btn-primary)
32
+ * 12. Drag-and-drop (drag-active)
33
+ * 13. HTMX (htmx-indicator)
34
+ * 14. Animations (keyframes + helper classes)
35
+ * 15. Toast notices
36
+ */
37
+
38
+ /* ── 1. Base / Reset ────────────────────────────────────────────────────────── */
39
+
40
+ html {
41
+ color-scheme: dark;
42
+ scroll-behavior: smooth;
43
+ -webkit-font-smoothing: antialiased;
44
+ -moz-osx-font-smoothing: grayscale;
45
+ }
46
+
47
+ body {
48
+ background-color: #0b1326;
49
+ color: #dae2fd;
50
+ font-family: 'Inter', ui-sans-serif, system-ui, sans-serif;
51
+ min-height: 100vh;
52
+ margin: 0;
53
+ }
54
+
55
+ *,
56
+ *::before,
57
+ *::after {
58
+ box-sizing: border-box;
59
+ }
60
+
61
+ /* ── 2. Material Symbols Outlined ───────────────────────────────────────────── */
62
+ /*
63
+ * Mirrors the class defined in React's index.css exactly.
64
+ * The font itself is loaded via Google Fonts CDN in base.html.
65
+ */
66
+ .material-symbols-outlined {
67
+ font-family: 'Material Symbols Outlined';
68
+ font-weight: normal;
69
+ font-style: normal;
70
+ font-size: 20px;
71
+ line-height: 1;
72
+ letter-spacing: normal;
73
+ text-transform: none;
74
+ display: inline-block;
75
+ white-space: nowrap;
76
+ word-wrap: normal;
77
+ direction: ltr;
78
+ -webkit-font-smoothing: antialiased;
79
+ user-select: none;
80
+ vertical-align: middle;
81
+ }
82
+
83
+ /* ── 3. Scrollbar ────────────────────────────────────────────────────────────── */
84
+
85
+ ::-webkit-scrollbar { width: 6px; height: 6px; }
86
+ ::-webkit-scrollbar-track { background: transparent; }
87
+ ::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 3px; }
88
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.22); }
89
+
90
+ /* ── 4. Focus ring ──────────────────────────────────────────────────────────── */
91
+
92
+ :focus-visible {
93
+ outline: 2px solid #c0c1ff;
94
+ outline-offset: 2px;
95
+ }
96
+
97
+ /* ── 5. Layout helpers ──────────────────────────────────────────────────────── */
98
+
99
+ .glass-panel {
100
+ background-color: rgba(23, 31, 51, 0.75);
101
+ backdrop-filter: blur(12px);
102
+ -webkit-backdrop-filter: blur(12px);
103
+ border: 1px solid rgba(255, 255, 255, 0.06);
104
+ }
105
+
106
+ /* Sidebar slide-in / slide-out on mobile */
107
+ .sidebar {
108
+ transform: translateX(-100%);
109
+ transition: transform 250ms ease-out;
110
+ }
111
+ .sidebar.sidebar--open {
112
+ transform: translateX(0);
113
+ }
114
+ @media (min-width: 768px) {
115
+ .sidebar {
116
+ transform: translateX(0);
117
+ }
118
+ }
119
+
120
+ /* Mobile overlay (backdrop) */
121
+ .sidebar-overlay {
122
+ display: none;
123
+ position: fixed;
124
+ inset: 0;
125
+ background-color: rgba(0, 0, 0, 0.6);
126
+ backdrop-filter: blur(4px);
127
+ -webkit-backdrop-filter: blur(4px);
128
+ z-index: 40;
129
+ }
130
+ .sidebar-overlay.sidebar-overlay--visible {
131
+ display: block;
132
+ }
133
+
134
+ /* ── 6. Navigation ──────────────────────────────────────────────────────────── */
135
+
136
+ .nav-item {
137
+ display: flex;
138
+ align-items: center;
139
+ gap: 12px;
140
+ padding: 10px 12px;
141
+ border-radius: 8px;
142
+ color: #c7c4d7;
143
+ font-size: 14px;
144
+ line-height: 20px;
145
+ font-weight: 500;
146
+ text-decoration: none;
147
+ cursor: pointer;
148
+ transition: color 150ms ease, background-color 150ms ease;
149
+ }
150
+ .nav-item:hover {
151
+ background-color: rgba(255, 255, 255, 0.05);
152
+ color: #dae2fd;
153
+ }
154
+
155
+ .nav-item-active {
156
+ display: flex;
157
+ align-items: center;
158
+ gap: 12px;
159
+ padding: 10px 12px;
160
+ border-radius: 8px;
161
+ color: #c0c1ff;
162
+ background-color: rgba(255, 255, 255, 0.07);
163
+ border-right: 2px solid #c0c1ff;
164
+ font-size: 14px;
165
+ line-height: 20px;
166
+ font-weight: 600;
167
+ text-decoration: none;
168
+ }
169
+
170
+ /* ── 7. Badges ──────────────────────────────────────────────────────────────── */
171
+ /*
172
+ * All badges share the same structural CSS. The colour variant is applied
173
+ * via the class suffix. Each badge is intentionally uppercase + monospace
174
+ * to match the React component styling.
175
+ */
176
+
177
+ .badge-base {
178
+ display: inline-flex;
179
+ align-items: center;
180
+ gap: 6px;
181
+ padding: 2px 8px;
182
+ border-radius: 9999px;
183
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
184
+ font-size: 11px;
185
+ line-height: 16px;
186
+ font-weight: 500;
187
+ letter-spacing: 0.06em;
188
+ text-transform: uppercase;
189
+ }
190
+
191
+ .badge-positive {
192
+ display: inline-flex;
193
+ align-items: center;
194
+ gap: 6px;
195
+ padding: 2px 8px;
196
+ border-radius: 9999px;
197
+ background-color: rgba(78, 222, 163, 0.10);
198
+ color: #4edea3;
199
+ border: 1px solid rgba(78, 222, 163, 0.25);
200
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
201
+ font-size: 11px;
202
+ line-height: 16px;
203
+ font-weight: 500;
204
+ letter-spacing: 0.06em;
205
+ text-transform: uppercase;
206
+ }
207
+
208
+ .badge-negative {
209
+ display: inline-flex;
210
+ align-items: center;
211
+ gap: 6px;
212
+ padding: 2px 8px;
213
+ border-radius: 9999px;
214
+ background-color: rgba(255, 180, 171, 0.10);
215
+ color: #ffb4ab;
216
+ border: 1px solid rgba(255, 180, 171, 0.25);
217
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
218
+ font-size: 11px;
219
+ line-height: 16px;
220
+ font-weight: 500;
221
+ letter-spacing: 0.06em;
222
+ text-transform: uppercase;
223
+ }
224
+
225
+ .badge-neutral {
226
+ display: inline-flex;
227
+ align-items: center;
228
+ gap: 6px;
229
+ padding: 2px 8px;
230
+ border-radius: 9999px;
231
+ background-color: rgba(144, 143, 160, 0.10);
232
+ color: #c7c4d7;
233
+ border: 1px solid rgba(144, 143, 160, 0.25);
234
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
235
+ font-size: 11px;
236
+ line-height: 16px;
237
+ font-weight: 500;
238
+ letter-spacing: 0.06em;
239
+ text-transform: uppercase;
240
+ }
241
+
242
+ .badge-processing {
243
+ display: inline-flex;
244
+ align-items: center;
245
+ gap: 6px;
246
+ padding: 2px 8px;
247
+ border-radius: 9999px;
248
+ background-color: rgba(192, 193, 255, 0.10);
249
+ color: #c0c1ff;
250
+ border: 1px solid rgba(192, 193, 255, 0.25);
251
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
252
+ font-size: 11px;
253
+ line-height: 16px;
254
+ font-weight: 500;
255
+ letter-spacing: 0.06em;
256
+ text-transform: uppercase;
257
+ animation: pulse-badge 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
258
+ }
259
+
260
+ .badge-error {
261
+ display: inline-flex;
262
+ align-items: center;
263
+ gap: 6px;
264
+ padding: 2px 8px;
265
+ border-radius: 9999px;
266
+ background-color: rgba(255, 180, 171, 0.10);
267
+ color: #ffb4ab;
268
+ border: 1px solid rgba(255, 180, 171, 0.25);
269
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
270
+ font-size: 11px;
271
+ line-height: 16px;
272
+ font-weight: 500;
273
+ letter-spacing: 0.06em;
274
+ text-transform: uppercase;
275
+ }
276
+
277
+ /* Dot inside badge */
278
+ .badge-dot {
279
+ width: 6px;
280
+ height: 6px;
281
+ border-radius: 9999px;
282
+ flex-shrink: 0;
283
+ display: inline-block;
284
+ }
285
+ .badge-dot--positive { background-color: #4edea3; }
286
+ .badge-dot--negative { background-color: #ffb4ab; }
287
+ .badge-dot--neutral { background-color: #908fa0; }
288
+ .badge-dot--primary { background-color: #c0c1ff; }
289
+ .badge-dot--error { background-color: #ffb4ab; }
290
+
291
+ /* ── 8. Highlights (annotated text) ─────────────────────────────────────────── */
292
+ /*
293
+ * Applied by the server-side annotated-text builder (Phase 3) to wrap
294
+ * aspect spans inside the review text.
295
+ */
296
+
297
+ .highlight-positive {
298
+ background-color: rgba(78, 222, 163, 0.15);
299
+ color: #4edea3;
300
+ border: 1px solid rgba(78, 222, 163, 0.30);
301
+ border-radius: 4px;
302
+ padding: 0 4px;
303
+ margin: 0 2px;
304
+ font-weight: 500;
305
+ }
306
+
307
+ .highlight-negative {
308
+ background-color: rgba(255, 180, 171, 0.15);
309
+ color: #ffb4ab;
310
+ border: 1px solid rgba(255, 180, 171, 0.30);
311
+ border-radius: 4px;
312
+ padding: 0 4px;
313
+ margin: 0 2px;
314
+ font-weight: 500;
315
+ }
316
+
317
+ .highlight-neutral {
318
+ background-color: rgba(144, 143, 160, 0.15);
319
+ color: #c7c4d7;
320
+ border: 1px solid rgba(144, 143, 160, 0.25);
321
+ border-radius: 4px;
322
+ padding: 0 4px;
323
+ margin: 0 2px;
324
+ font-weight: 500;
325
+ }
326
+
327
+ /* ── 9. Cards ────────────────────────────────────────────────────────────────── */
328
+
329
+ .card {
330
+ background-color: #171f33;
331
+ border-radius: 12px;
332
+ border: 1px solid rgba(255, 255, 255, 0.08);
333
+ padding: 24px;
334
+ }
335
+
336
+ .card-low {
337
+ background-color: #131b2e;
338
+ border-radius: 12px;
339
+ border: 1px solid rgba(255, 255, 255, 0.06);
340
+ padding: 24px;
341
+ }
342
+
343
+ .stat-card {
344
+ background-color: #171f33;
345
+ border-radius: 12px;
346
+ border: 1px solid rgba(255, 255, 255, 0.08);
347
+ padding: 24px;
348
+ position: relative;
349
+ overflow: hidden;
350
+ }
351
+
352
+ /* ── 10. Form controls ──────────────────────────────────────────────────────── */
353
+
354
+ .input-base {
355
+ background-color: #0b1326;
356
+ border: 1px solid rgba(255, 255, 255, 0.12);
357
+ border-radius: 8px;
358
+ padding: 8px 12px;
359
+ font-size: 14px;
360
+ line-height: 20px;
361
+ color: #dae2fd;
362
+ width: 100%;
363
+ transition: border-color 150ms ease, box-shadow 150ms ease;
364
+ appearance: none;
365
+ -webkit-appearance: none;
366
+ }
367
+
368
+ .input-base::placeholder {
369
+ color: rgba(199, 196, 215, 0.60);
370
+ }
371
+
372
+ .input-base:focus {
373
+ outline: none;
374
+ border-color: #c0c1ff;
375
+ box-shadow: 0 0 0 1px rgba(192, 193, 255, 0.40);
376
+ }
377
+
378
+ /* Select arrow */
379
+ select.input-base {
380
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23c7c4d7' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
381
+ background-repeat: no-repeat;
382
+ background-position: right 10px center;
383
+ padding-right: 32px;
384
+ cursor: pointer;
385
+ }
386
+
387
+ /* Textarea */
388
+ textarea.input-base {
389
+ resize: vertical;
390
+ font-family: inherit;
391
+ line-height: 1.6;
392
+ }
393
+
394
+ /* ── 11. Button — primary ────────────────────────────────────────────────────── */
395
+
396
+ .btn-primary {
397
+ display: inline-flex;
398
+ align-items: center;
399
+ justify-content: center;
400
+ gap: 8px;
401
+ background-color: #c0c1ff;
402
+ color: #1000a9;
403
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
404
+ font-size: 12px;
405
+ line-height: 16px;
406
+ font-weight: 500;
407
+ letter-spacing: 0.05em;
408
+ padding: 10px 20px;
409
+ border-radius: 8px;
410
+ border: none;
411
+ cursor: pointer;
412
+ text-decoration: none;
413
+ transition: filter 150ms ease, transform 150ms ease;
414
+ white-space: nowrap;
415
+ }
416
+ .btn-primary:hover { filter: brightness(1.10); }
417
+ .btn-primary:active { transform: scale(0.98); }
418
+ .btn-primary:disabled,
419
+ .btn-primary[disabled] {
420
+ opacity: 0.50;
421
+ cursor: not-allowed;
422
+ transform: none;
423
+ pointer-events: none;
424
+ }
425
+
426
+ /* ── 12. Drag-and-drop ──────────────────────────────────────────────────────── */
427
+
428
+ .drag-active {
429
+ border-color: rgba(192, 193, 255, 0.70) !important;
430
+ background-color: rgba(192, 193, 255, 0.04) !important;
431
+ }
432
+
433
+ /* ── 13. HTMX indicators ────────────────────────────────────────────────────── */
434
+ /*
435
+ * HTMX adds .htmx-request to the element that triggered the request.
436
+ * Elements with .htmx-indicator are hidden by default and shown during request.
437
+ */
438
+ .htmx-indicator { display: none; }
439
+ .htmx-request .htmx-indicator { display: flex; }
440
+ .htmx-request.htmx-indicator { display: flex; }
441
+
442
+ /* Progress bar fill animation */
443
+ .progress-bar {
444
+ transition: width 500ms ease;
445
+ }
446
+
447
+ /* ── 14. Animations ──────────────────────────────────────────────────────────── */
448
+
449
+ @keyframes fadeIn {
450
+ from { opacity: 0; }
451
+ to { opacity: 1; }
452
+ }
453
+
454
+ @keyframes slideIn {
455
+ from { opacity: 0; transform: translateY(8px); }
456
+ to { opacity: 1; transform: translateY(0); }
457
+ }
458
+
459
+ @keyframes pulse-badge {
460
+ 0%, 100% { opacity: 1; }
461
+ 50% { opacity: 0.6; }
462
+ }
463
+
464
+ @keyframes pulse-dot {
465
+ 0%, 100% { opacity: 1; }
466
+ 50% { opacity: 0.4; }
467
+ }
468
+
469
+ @keyframes spin {
470
+ from { transform: rotate(0deg); }
471
+ to { transform: rotate(360deg); }
472
+ }
473
+
474
+ .animate-fade-in { animation: fadeIn 0.20s ease-out; }
475
+ .animate-slide-in { animation: slideIn 0.25s ease-out; }
476
+ .animate-spin { animation: spin 1s linear infinite; }
477
+ .animate-pulse-slow { animation: pulse-dot 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
478
+
479
+ /* ── 15. Toast notifications ─────────────────────────────────────────────────── */
480
+ /*
481
+ * Toasts are managed by Alpine.js appState().
482
+ * Base styles here; position and z-index are set with Tailwind utilities in base.html.
483
+ */
484
+ .toast {
485
+ padding: 12px 16px;
486
+ border-radius: 8px;
487
+ border: 1px solid rgba(255, 255, 255, 0.08);
488
+ background-color: #222a3d;
489
+ color: #dae2fd;
490
+ font-size: 14px;
491
+ max-width: 380px;
492
+ pointer-events: auto;
493
+ transition: opacity 150ms ease, transform 150ms ease;
494
+ }
495
+ .toast--success { border-color: rgba(78, 222, 163, 0.30); color: #4edea3; }
496
+ .toast--error { border-color: rgba(255, 180, 171, 0.30); color: #ffb4ab; }
497
+ .toast--info { border-color: rgba(192, 193, 255, 0.20); color: #dae2fd; }
api/app/templates/base.html ADDED
@@ -0,0 +1,438 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>{% block title %}{{ page_title }}{% endblock %} — SentimentAI</title>
7
+ <meta name="description" content="{% block description %}Multilingual Aspect-Based Sentiment Analysis Dashboard{% endblock %}" />
8
+
9
+ {# ── Fonts ────────────────────────────────────────────────────────────────── #}
10
+ {# Inter replaces Geist (same design language, available on Google Fonts CDN). #}
11
+ {# JetBrains Mono is used verbatim from the original tailwind.config.js. #}
12
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
13
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
14
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
15
+ rel="stylesheet" />
16
+
17
+ {# ── Material Symbols Outlined icon font ──────────────────────────────────── #}
18
+ {# Variable-font version so FILL and wght axes are available (matching React). #}
19
+ <link rel="stylesheet"
20
+ href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
21
+
22
+ {#
23
+ ── Tailwind CSS (Play CDN) ───────────────────────────────────────────────
24
+ The CDN version scans the DOM at runtime and generates utilities on demand.
25
+ This eliminates the npm build step. Custom design tokens from the original
26
+ tailwind.config.js are provided in the tailwind.config object below.
27
+
28
+ NOTE: The config <script> MUST come AFTER the CDN <script> tag.
29
+ #}
30
+ <script src="https://cdn.tailwindcss.com"></script>
31
+ <script>
32
+ tailwind.config = {
33
+ darkMode: 'class',
34
+ theme: {
35
+ extend: {
36
+ colors: {
37
+ // Surface palette — from tailwind.config.js
38
+ "surface": "#0b1326",
39
+ "surface-dim": "#0b1326",
40
+ "surface-bright": "#31394d",
41
+ "surface-container-lowest": "#060e20",
42
+ "surface-container-low": "#131b2e",
43
+ "surface-container": "#171f33",
44
+ "surface-container-high": "#222a3d",
45
+ "surface-container-highest": "#2d3449",
46
+ "surface-variant": "#2d3449",
47
+ "background": "#0b1326",
48
+ // On-surface
49
+ "on-surface": "#dae2fd",
50
+ "on-surface-variant": "#c7c4d7",
51
+ "on-background": "#dae2fd",
52
+ // Primary
53
+ "primary": "#c0c1ff",
54
+ "primary-container": "#8083ff",
55
+ "on-primary": "#1000a9",
56
+ // Secondary
57
+ "secondary": "#c4c7c9",
58
+ // Tertiary (positive sentiment — green)
59
+ "tertiary": "#4edea3",
60
+ "tertiary-container": "#00885d",
61
+ "on-tertiary": "#003824",
62
+ // Error (negative sentiment — red)
63
+ "error": "#ffb4ab",
64
+ "error-container": "#93000a",
65
+ "on-error": "#690005",
66
+ // Outline
67
+ "outline": "#908fa0",
68
+ "outline-variant": "#464554",
69
+ // Semantic aliases
70
+ "positive": "#4edea3",
71
+ "negative": "#ffb4ab",
72
+ "warning": "#f5c542",
73
+ },
74
+ fontFamily: {
75
+ sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
76
+ mono: ["JetBrains Mono", "ui-monospace", "monospace"],
77
+ },
78
+ fontSize: {
79
+ "display": ["36px", { lineHeight: "44px", letterSpacing: "-0.025em", fontWeight: "600" }],
80
+ "headline-lg": ["32px", { lineHeight: "40px", letterSpacing: "-0.02em", fontWeight: "600" }],
81
+ "headline-md": ["24px", { lineHeight: "32px", letterSpacing: "-0.01em", fontWeight: "600" }],
82
+ "headline-sm": ["20px", { lineHeight: "28px", fontWeight: "500" }],
83
+ "title-lg": ["16px", { lineHeight: "24px", fontWeight: "600" }],
84
+ "title-md": ["14px", { lineHeight: "20px", fontWeight: "600" }],
85
+ "body-lg": ["16px", { lineHeight: "24px", fontWeight: "400" }],
86
+ "body-md": ["14px", { lineHeight: "20px", fontWeight: "400" }],
87
+ "body-sm": ["12px", { lineHeight: "16px", fontWeight: "400" }],
88
+ "label-lg": ["14px", { lineHeight: "20px", letterSpacing: "0.02em", fontWeight: "500" }],
89
+ "label-md": ["12px", { lineHeight: "16px", letterSpacing: "0.05em", fontWeight: "500" }],
90
+ "label-sm": ["11px", { lineHeight: "16px", letterSpacing: "0.06em", fontWeight: "500" }],
91
+ },
92
+ spacing: {
93
+ "xs": "4px",
94
+ "sm": "8px",
95
+ "md": "12px",
96
+ "lg": "16px",
97
+ "xl": "24px",
98
+ "2xl": "32px",
99
+ "3xl": "48px",
100
+ },
101
+ borderRadius: {
102
+ "sm": "4px",
103
+ "DEFAULT": "6px",
104
+ "md": "8px",
105
+ "lg": "12px",
106
+ "xl": "16px",
107
+ "2xl": "20px",
108
+ "full": "9999px",
109
+ },
110
+ boxShadow: {
111
+ "card": "0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)",
112
+ "elevated": "0 4px 16px rgba(0,0,0,0.5)",
113
+ "glow-primary": "0 0 20px rgba(192,193,255,0.15)",
114
+ "glow-positive": "0 0 12px rgba(78,222,163,0.20)",
115
+ "glow-negative": "0 0 12px rgba(255,180,171,0.20)",
116
+ },
117
+ backdropBlur: {
118
+ "glass": "12px",
119
+ },
120
+ animation: {
121
+ "pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
122
+ "fade-in": "fadeIn 0.2s ease-out",
123
+ "slide-in": "slideIn 0.25s ease-out",
124
+ },
125
+ keyframes: {
126
+ fadeIn: { from: { opacity: "0" }, to: { opacity: "1" } },
127
+ slideIn: { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
128
+ },
129
+ // Safelist ensures Alpine.js-toggled classes are always generated
130
+ safelist: ["translate-x-0", "-translate-x-full"],
131
+ },
132
+ },
133
+ };
134
+ </script>
135
+
136
+ {# ── Custom design system CSS ──────────────────────────────────────────────── #}
137
+ {# Component classes that use @apply in the React version are written as plain #}
138
+ {# CSS in app.css (since the Play CDN does not process @apply in external CSS). #}
139
+ <link rel="stylesheet" href="{{ request.url_for('static', path='css/app.css') }}" />
140
+
141
+ {# ── Per-page extra head content ──────────────────────────────────────────── #}
142
+ {% block head %}{% endblock %}
143
+ </head>
144
+
145
+ <body class="bg-[#0b1326] text-[#dae2fd] antialiased font-sans"
146
+ x-data="appState()"
147
+ @notify.window="addToast($event.detail.msg, $event.detail.type)">
148
+
149
+ {# ── Toast notification container ────────────────────────────────────────── #}
150
+ {# Managed by Alpine.js appState(). HTMX error events dispatch to this. #}
151
+ <div class="fixed top-4 right-4 z-[100] space-y-2 pointer-events-none w-80"
152
+ role="status"
153
+ aria-live="polite"
154
+ aria-atomic="false">
155
+ <template x-for="toast in toasts" :key="toast.id">
156
+ <div x-show="toast.visible"
157
+ x-transition:enter="transition ease-out duration-200"
158
+ x-transition:enter-start="opacity-0 translate-y-1"
159
+ x-transition:enter-end="opacity-100 translate-y-0"
160
+ x-transition:leave="transition ease-in duration-150"
161
+ x-transition:leave-start="opacity-100"
162
+ x-transition:leave-end="opacity-0"
163
+ class="toast pointer-events-auto"
164
+ :class="{
165
+ 'toast--success': toast.type === 'success',
166
+ 'toast--error': toast.type === 'error',
167
+ 'toast--info': toast.type === 'info',
168
+ }"
169
+ x-text="toast.msg">
170
+ </div>
171
+ </template>
172
+ </div>
173
+
174
+ {# ── Page wrapper ─────────────────────────────────────────────────────────── #}
175
+ <div class="min-h-screen flex">
176
+
177
+ {# ── Mobile sidebar overlay ──────────────────────────────────────────────── #}
178
+ <div x-show="sidebarOpen"
179
+ x-transition:enter="transition ease-out duration-200"
180
+ x-transition:enter-start="opacity-0"
181
+ x-transition:enter-end="opacity-100"
182
+ x-transition:leave="transition ease-in duration-150"
183
+ x-transition:leave-start="opacity-100"
184
+ x-transition:leave-end="opacity-0"
185
+ @click="sidebarOpen = false"
186
+ class="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
187
+ aria-hidden="true"
188
+ style="display: none;"></div>
189
+
190
+ {# ── Sidebar ─────────────────────────────────────────��───────────────────── #}
191
+ {#
192
+ The sidebar is always visible on desktop (md:translate-x-0) and slides in
193
+ on mobile when sidebarOpen is true. Alpine.js toggles the translation via
194
+ :class binding. The safelist in tailwind.config ensures translate-x-0 and
195
+ -translate-x-full are always generated by the CDN.
196
+ #}
197
+ <nav aria-label="Main navigation"
198
+ :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
199
+ class="fixed left-0 top-0 h-screen w-64 z-50 flex flex-col
200
+ bg-[#171f33] border-r border-white/[0.07]
201
+ transition-transform duration-[250ms] ease-out
202
+ md:translate-x-0">
203
+
204
+ {# Logo #}
205
+ <div class="flex items-center gap-3 px-xl py-xl">
206
+ <span class="material-symbols-outlined text-[#c0c1ff]"
207
+ style="font-size:28px; font-variation-settings: 'FILL' 1, 'wght' 400;"
208
+ aria-hidden="true">psychology</span>
209
+ <div>
210
+ <p class="text-[16px] font-semibold text-[#c0c1ff] leading-tight">SentimentAI</p>
211
+ <p class="font-mono text-[11px] text-[#c7c4d7] tracking-wider">Analysis Engine v2.4</p>
212
+ </div>
213
+ </div>
214
+
215
+ {# CTA — New Analysis #}
216
+ <div class="px-xl mb-xl">
217
+ <a href="/predict"
218
+ @click="sidebarOpen = false"
219
+ class="btn-primary w-full text-[12px]">
220
+ <span class="material-symbols-outlined"
221
+ style="font-size:18px; font-variation-settings: 'FILL' 0, 'wght' 300;"
222
+ aria-hidden="true">add</span>
223
+ New Analysis
224
+ </a>
225
+ </div>
226
+
227
+ {# Navigation links #}
228
+ <ul class="flex-1 px-sm space-y-0.5 overflow-y-auto" role="list">
229
+ {% for item in nav_items %}
230
+ {% set active = current_path == item.path or (current_path == '/' and item.path == '/predict') %}
231
+ <li role="listitem">
232
+ <a href="{{ item.path }}"
233
+ @click="sidebarOpen = false"
234
+ class="{{ 'nav-item-active' if active else 'nav-item' }}"
235
+ {% if active %}aria-current="page"{% endif %}>
236
+ <span class="material-symbols-outlined"
237
+ style="font-size:20px; font-variation-settings: 'FILL' {{ '1' if active else '0' }}, 'wght' 300;"
238
+ aria-hidden="true">{{ item.icon }}</span>
239
+ <span>{{ item.label }}</span>
240
+ </a>
241
+ </li>
242
+ {% endfor %}
243
+ </ul>
244
+
245
+ {# Bottom section: health pill + secondary nav #}
246
+ <div class="px-sm pt-sm pb-xl border-t border-white/[0.06] space-y-0.5 mt-auto">
247
+
248
+ {# API health indicator — static in Phase 2; HTMX polling added in Phase 5 #}
249
+ <div id="sidebar-health"
250
+ class="flex items-center gap-2 px-3 py-2 mb-1">
251
+ <span class="w-2 h-2 rounded-full bg-[#4edea3] flex-shrink-0 animate-pulse-slow"
252
+ aria-hidden="true"></span>
253
+ <span class="font-mono text-[12px] text-[#c7c4d7]">API Online</span>
254
+ </div>
255
+
256
+ <a href="/monitor"
257
+ @click="sidebarOpen = false"
258
+ class="{{ 'nav-item-active' if current_path == '/monitor' else 'nav-item' }}">
259
+ <span class="material-symbols-outlined"
260
+ style="font-size:20px; font-variation-settings: 'FILL' 0, 'wght' 300;"
261
+ aria-hidden="true">settings</span>
262
+ <span>Settings</span>
263
+ </a>
264
+
265
+ <a href="/docs"
266
+ target="_blank"
267
+ rel="noopener noreferrer"
268
+ class="nav-item">
269
+ <span class="material-symbols-outlined"
270
+ style="font-size:20px; font-variation-settings: 'FILL' 0, 'wght' 300;"
271
+ aria-hidden="true">menu_book</span>
272
+ <span>API Docs</span>
273
+ </a>
274
+ </div>
275
+ </nav>
276
+
277
+ {# ── Main area (right of sidebar) ────────────────────────────────────────── #}
278
+ <div class="flex-1 md:ml-64 flex flex-col min-h-screen">
279
+
280
+ {# Top header bar #}
281
+ <header class="fixed top-0 right-0 left-0 md:left-64 h-16 z-30
282
+ bg-[#0b1326]/80 backdrop-blur-[12px]
283
+ border-b border-white/[0.06]
284
+ flex items-center justify-between px-xl gap-4">
285
+
286
+ <div class="flex items-center gap-3">
287
+ {# Mobile hamburger #}
288
+ <button @click="sidebarOpen = true"
289
+ class="md:hidden p-2 rounded-lg text-[#c7c4d7]
290
+ hover:text-[#dae2fd] hover:bg-white/[0.05]
291
+ transition-colors"
292
+ aria-label="Open navigation"
293
+ aria-expanded="false"
294
+ :aria-expanded="sidebarOpen">
295
+ <span class="material-symbols-outlined"
296
+ style="font-size:22px; font-variation-settings: 'FILL' 0, 'wght' 300;"
297
+ aria-hidden="true">menu</span>
298
+ </button>
299
+
300
+ {# Mobile brand (hidden on desktop) #}
301
+ <div class="md:hidden flex items-center gap-2">
302
+ <span class="material-symbols-outlined text-[#c0c1ff]"
303
+ style="font-size:22px; font-variation-settings: 'FILL' 1, 'wght' 400;"
304
+ aria-hidden="true">psychology</span>
305
+ <span class="font-semibold text-[#dae2fd]">SentimentAI</span>
306
+ </div>
307
+
308
+ {# Desktop page title (hidden on mobile) #}
309
+ <h2 class="hidden md:block text-body-md font-medium text-[#c7c4d7]">
310
+ {{ page_title }}
311
+ </h2>
312
+ </div>
313
+
314
+ {# Right action group #}
315
+ <div class="flex items-center gap-2">
316
+ <button class="p-2 rounded-lg text-[#c7c4d7]
317
+ hover:text-[#dae2fd] hover:bg-white/[0.05]
318
+ transition-colors"
319
+ aria-label="Notifications">
320
+ <span class="material-symbols-outlined"
321
+ style="font-size:20px; font-variation-settings: 'FILL' 0, 'wght' 300;"
322
+ aria-hidden="true">notifications</span>
323
+ </button>
324
+
325
+ <a href="/docs"
326
+ target="_blank"
327
+ rel="noopener noreferrer"
328
+ class="hidden sm:flex items-center gap-1.5 px-3 py-1.5
329
+ border border-white/[0.12] rounded-lg
330
+ font-mono text-[12px] text-[#c7c4d7]
331
+ hover:text-[#dae2fd] hover:border-white/25
332
+ transition-colors duration-150">
333
+ <span class="material-symbols-outlined"
334
+ style="font-size:14px; font-variation-settings: 'FILL' 0, 'wght' 300;"
335
+ aria-hidden="true">api</span>
336
+ API Docs
337
+ </a>
338
+
339
+ {# Avatar placeholder #}
340
+ <div class="w-8 h-8 rounded-full bg-[#222a3d]
341
+ border border-white/[0.12]
342
+ flex items-center justify-center
343
+ text-[#c7c4d7] select-none"
344
+ role="img"
345
+ aria-label="User avatar">
346
+ <span class="material-symbols-outlined"
347
+ style="font-size:18px; font-variation-settings: 'FILL' 0, 'wght' 300;"
348
+ aria-hidden="true">person</span>
349
+ </div>
350
+ </div>
351
+ </header>
352
+
353
+ {# Page content area #}
354
+ <main class="flex-1 pt-16 overflow-y-auto" id="main-content">
355
+ <div class="max-w-[1280px] mx-auto px-lg md:px-3xl py-xl md:py-2xl">
356
+ {% block content %}{% endblock %}
357
+ </div>
358
+ </main>
359
+
360
+ {# Footer #}
361
+ <footer class="border-t border-white/[0.05] py-4 text-center font-mono text-label-sm text-[#c7c4d7]/50">
362
+ SentimentAI — Multilingual ABSA Dashboard
363
+ </footer>
364
+
365
+ </div>{# /main area #}
366
+ </div>{# /page wrapper #}
367
+
368
+ {# ── JavaScript ────────────────────────────────────────────────────────────── #}
369
+
370
+ {#
371
+ Alpine.js state — defined BEFORE the defer script so appState() is in scope
372
+ when Alpine.js initialises after DOMContentLoaded.
373
+ #}
374
+ <script>
375
+ /**
376
+ * appState()
377
+ * ----------
378
+ * Root Alpine.js component mounted on <body>.
379
+ *
380
+ * Responsibilities:
381
+ * - sidebarOpen : mobile sidebar toggle state
382
+ * - toasts : notification queue for HTMX errors and success messages
383
+ * - addToast() : push a message (called via @notify.window event)
384
+ *
385
+ * HTMX events are converted to CustomEvents ("notify") which Alpine.js
386
+ * listens for via @notify.window. This keeps HTMX and Alpine.js decoupled.
387
+ */
388
+ function appState() {
389
+ return {
390
+ sidebarOpen: false,
391
+ toasts: [],
392
+
393
+ addToast(msg, type = 'info') {
394
+ const toast = { id: Date.now() + Math.random(), msg, type, visible: true };
395
+ this.toasts.push(toast);
396
+ // Auto-dismiss after 3.5 s
397
+ setTimeout(() => {
398
+ toast.visible = false;
399
+ // Remove from array after fade-out completes
400
+ setTimeout(() => {
401
+ this.toasts = this.toasts.filter(t => t.id !== toast.id);
402
+ }, 200);
403
+ }, 3500);
404
+ },
405
+ };
406
+ }
407
+
408
+ // ── HTMX → notify bridge ─────────────────────────────────────────────────
409
+ // Translate HTMX lifecycle events into the "notify" CustomEvent so Alpine.js
410
+ // can display them without tightly coupling HTMX handlers to Alpine components.
411
+
412
+ document.addEventListener('htmx:responseError', function (e) {
413
+ let msg = 'Request failed. Please try again.';
414
+ try {
415
+ const body = JSON.parse(e.detail.xhr.responseText);
416
+ msg = body.detail || msg;
417
+ } catch (_) { /* not JSON — use default */ }
418
+ window.dispatchEvent(new CustomEvent('notify', { detail: { msg, type: 'error' } }));
419
+ });
420
+
421
+ document.addEventListener('htmx:sendError', function () {
422
+ window.dispatchEvent(new CustomEvent('notify', {
423
+ detail: { msg: 'Network error — check your connection.', type: 'error' }
424
+ }));
425
+ });
426
+ </script>
427
+
428
+ {# HTMX — loaded deferred so it does not block rendering #}
429
+ <script src="https://unpkg.com/htmx.org@1.9.12/dist/htmx.min.js" defer></script>
430
+
431
+ {# Alpine.js v3 — loaded deferred; appState() above is already in global scope #}
432
+ <script defer src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js"></script>
433
+
434
+ {# ── Per-page extra scripts ───────────────────────────────────────────────── #}
435
+ {% block scripts %}{% endblock %}
436
+
437
+ </body>
438
+ </html>
api/app/templates/macros/ui.html ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {#
2
+ macros/ui.html — Reusable Jinja2 macros for the SentimentAI dashboard.
3
+
4
+ WHY THIS FILE EXISTS
5
+ --------------------
6
+ The React components (Sidebar.jsx, Monitor.jsx, Analytics.jsx) defined small
7
+ helper components (MSIcon, StatusBadge, HealthChip, InfoRow) that were reused
8
+ across pages. Jinja2 macros provide the equivalent pattern: define once,
9
+ import anywhere.
10
+
11
+ USAGE IN TEMPLATES
12
+ ------------------
13
+ {% from "macros/ui.html" import ms_icon, status_badge, health_chip, sentiment_badge %}
14
+
15
+ Each macro produces self-contained HTML with no JavaScript dependencies.
16
+ CSS classes reference app.css component classes (badge-*, highlight-*) and
17
+ Tailwind utilities from the CDN.
18
+ #}
19
+
20
+ {# ── Material Symbols Outlined icon ───────────────────────────────────────── #}
21
+ {#
22
+ ms_icon(name, filled, size, cls)
23
+ ---------------------------------
24
+ Renders a Material Symbols Outlined icon span.
25
+
26
+ Parameters
27
+ ----------
28
+ name : str — icon name e.g. "psychology", "cloud_upload", "bolt"
29
+ filled : bool — whether to use FILL=1 (solid) or FILL=0 (outlined)
30
+ size : int — font-size in px (default 20)
31
+ cls : str — extra CSS classes appended to the span
32
+ #}
33
+ {% macro ms_icon(name, filled=False, size=20, cls='') %}
34
+ <span class="material-symbols-outlined {{ cls }}"
35
+ style="font-size: {{ size }}px; font-variation-settings: 'FILL' {{ 1 if filled else 0 }}, 'wght' {{ 400 if filled else 300 }};"
36
+ aria-hidden="true">{{ name }}</span>
37
+ {% endmacro %}
38
+
39
+
40
+ {# ── Status badge ─────────────────────────────────────────────────────────── #}
41
+ {#
42
+ status_badge(status)
43
+ ---------------------
44
+ Renders a coloured badge for a batch job status.
45
+
46
+ status values: "completed" | "processing" | "queued" | "failed"
47
+ #}
48
+ {% macro status_badge(status) %}
49
+ {% if status == 'completed' %}
50
+ <span class="badge-positive">
51
+ <span class="badge-dot badge-dot--positive"></span>
52
+ Completed
53
+ </span>
54
+ {% elif status == 'processing' %}
55
+ <span class="badge-processing">
56
+ <span class="badge-dot badge-dot--primary animate-pulse-slow"></span>
57
+ Processing
58
+ </span>
59
+ {% elif status == 'queued' %}
60
+ <span class="badge-neutral">
61
+ <span class="badge-dot badge-dot--neutral"></span>
62
+ Queued
63
+ </span>
64
+ {% elif status == 'failed' %}
65
+ <span class="badge-error">
66
+ <span class="badge-dot badge-dot--error"></span>
67
+ Failed
68
+ </span>
69
+ {% else %}
70
+ <span class="badge-neutral">{{ status }}</span>
71
+ {% endif %}
72
+ {% endmacro %}
73
+
74
+
75
+ {# ── Health chip ──────────────────────────────────────────────────────────── #}
76
+ {#
77
+ health_chip(ok)
78
+ ----------------
79
+ Renders a "Healthy" or "Degraded" status chip.
80
+
81
+ ok : bool — True if the API health check returned status=="ok"
82
+ #}
83
+ {% macro health_chip(ok) %}
84
+ {% if ok %}
85
+ <span class="badge-positive">
86
+ <span class="badge-dot badge-dot--positive animate-pulse-slow"></span>
87
+ Healthy
88
+ </span>
89
+ {% else %}
90
+ <span class="badge-error">
91
+ <span class="badge-dot badge-dot--error animate-pulse-slow"></span>
92
+ Degraded
93
+ </span>
94
+ {% endif %}
95
+ {% endmacro %}
96
+
97
+
98
+ {# ── Sentiment badge ──────────────────────────────────────────────────────── #}
99
+ {#
100
+ sentiment_badge(sentiment)
101
+ ---------------------------
102
+ Renders the sentiment label for an aspect card.
103
+
104
+ sentiment values: "positive" | "negative" | "neutral" | "conflict"
105
+ #}
106
+ {% macro sentiment_badge(sentiment) %}
107
+ {% if sentiment == 'positive' %}
108
+ <span class="badge-positive">
109
+ <span class="badge-dot badge-dot--positive"></span>
110
+ {{ sentiment }}
111
+ </span>
112
+ {% elif sentiment == 'negative' %}
113
+ <span class="badge-negative">
114
+ <span class="badge-dot badge-dot--negative"></span>
115
+ {{ sentiment }}
116
+ </span>
117
+ {% elif sentiment == 'conflict' %}
118
+ <span class="badge-error">
119
+ <span class="badge-dot badge-dot--error"></span>
120
+ {{ sentiment }}
121
+ </span>
122
+ {% else %}
123
+ <span class="badge-neutral">
124
+ <span class="badge-dot badge-dot--neutral"></span>
125
+ {{ sentiment }}
126
+ </span>
127
+ {% endif %}
128
+ {% endmacro %}
129
+
130
+
131
+ {# ── Info row (used on Monitor page) ─────────────────────────────────────── #}
132
+ {#
133
+ info_row(label, value, value_cls)
134
+ -----------------------------------
135
+ Renders a labelled key-value cell inside the Model Configuration card.
136
+ #}
137
+ {% macro info_row(label, value, value_cls='') %}
138
+ <div class="bg-[#0b1326] rounded-lg p-3">
139
+ <p class="font-mono text-[11px] text-[#c7c4d7] mb-1 uppercase tracking-wide">{{ label }}</p>
140
+ <p class="text-sm text-[#dae2fd] font-medium {{ value_cls }}">{{ value }}</p>
141
+ </div>
142
+ {% endmacro %}
143
+
144
+
145
+ {# ── Loaded badge (aspect/sentiment model status) ────────────────────────── #}
146
+ {% macro loaded_badge() %}
147
+ <div class="flex items-center gap-1.5 text-sm text-[#dae2fd] font-medium">
148
+ {{ ms_icon('check_circle', filled=False, size=16, cls='text-[#4edea3]') }}
149
+ Loaded
150
+ </div>
151
+ {% endmacro %}
152
+
153
+
154
+ {# ── Skeleton placeholder (used during HTMX loading states) ─────────────── #}
155
+ {#
156
+ skeleton(height, width_cls)
157
+ ----------------------------
158
+ Renders a pulsing skeleton placeholder matching the React Skeleton component.
159
+ #}
160
+ {% macro skeleton(height='h-4', width_cls='w-full') %}
161
+ <div class="animate-pulse bg-[#222a3d] rounded {{ height }} {{ width_cls }}"></div>
162
+ {% endmacro %}
163
+
164
+
165
+ {# ── Empty-state panel ────────────────────────────────────────────────────── #}
166
+ {#
167
+ empty_state(icon, message)
168
+ ---------------------------
169
+ Centred icon + message for panels with no data yet.
170
+ #}
171
+ {% macro empty_state(icon='psychology', message='No data yet') %}
172
+ <div class="flex flex-col items-center justify-center gap-4 py-16 text-[#c7c4d7]">
173
+ <span class="material-symbols-outlined opacity-30"
174
+ style="font-size: 48px; font-variation-settings: 'FILL' 0, 'wght' 200;"
175
+ aria-hidden="true">{{ icon }}</span>
176
+ <p class="text-sm opacity-60">{{ message }}</p>
177
+ </div>
178
+ {% endmacro %}
api/app/templates/pages/batch.html ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% from "macros/ui.html" import ms_icon, status_badge, empty_state %}
3
+
4
+ {% block title %}Batch Analytics{% endblock %}
5
+ {% block description %}Upload a CSV of reviews for bulk aspect-based sentiment analysis.{% endblock %}
6
+
7
+ {% block content %}
8
+ {#
9
+ Phase 2: Placeholder layout for the Batch Analytics page.
10
+
11
+ What this page will contain after Phase 4:
12
+ - Upload zone : drag-and-drop CSV input (HTML5 + Alpine.js drag events)
13
+ - Progress card: job status + progress bar polling via hx-trigger="every 2s"
14
+ - Recent table : DB-queried list of past batch jobs
15
+ - Charts : post-completion AspectHeatmap, LanguagePie, SentimentChart
16
+ (Chart.js, rendered with server-provided JSON data)
17
+
18
+ The page structure below mirrors Analytics.jsx exactly so Phase 4 only needs to
19
+ replace placeholder content with functional forms and HTMX targets.
20
+ #}
21
+ <div class="space-y-xl">
22
+
23
+ {# ── Page header ─────────────────────────────────────────────────────────── #}
24
+ <div>
25
+ <h1 class="text-headline-md text-[#dae2fd]">Batch Analytics</h1>
26
+ <p class="mt-1 text-body-md text-[#c7c4d7]">
27
+ Upload a CSV of reviews for bulk aspect-based sentiment analysis.
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 ─────────────────────────────────────── #}
62
+ <section aria-label="Recent batch jobs">
63
+ <h2 class="text-headline-sm text-[#dae2fd] mb-lg">Recent Batches</h2>
64
+ <div class="card overflow-hidden p-0">
65
+ <div class="overflow-x-auto">
66
+ <table class="w-full text-left border-collapse" aria-label="Recent batch jobs">
67
+ <thead>
68
+ <tr class="border-b border-white/[0.08] bg-[#222a3d]/50">
69
+ <th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-xl">Filename</th>
70
+ <th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-lg">Rows</th>
71
+ <th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-lg">Status</th>
72
+ <th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-xl text-right">Date</th>
73
+ </tr>
74
+ </thead>
75
+ <tbody class="divide-y divide-white/[0.05]">
76
+ {# Phase 2: static mock rows matching React's Analytics.jsx mock data #}
77
+ {% for row in [
78
+ {'name': 'q3_customer_feedback.csv', 'rows': '4,250', 'status': 'completed', 'date': 'Today, 14:32'},
79
+ {'name': 'product_launch_tweets.csv', 'rows': '8,912', 'status': 'processing', 'date': 'Today, 14:15'},
80
+ {'name': 'corrupted_export_09.csv', 'rows': '—', 'status': 'failed', 'date': 'Yesterday'},
81
+ ] %}
82
+ <tr class="hover:bg-white/[0.03] transition-colors">
83
+ <td class="py-3 px-xl">
84
+ <div class="flex items-center gap-2 text-body-md text-[#dae2fd]">
85
+ {{ ms_icon('description', size=16, cls='text-[#c7c4d7]') }}
86
+ {{ row.name }}
87
+ </div>
88
+ </td>
89
+ <td class="py-3 px-lg font-mono text-body-sm text-[#c7c4d7]">{{ row.rows }}</td>
90
+ <td class="py-3 px-lg">{{ status_badge(row.status) }}</td>
91
+ <td class="py-3 px-xl text-right font-mono text-body-sm text-[#c7c4d7]">{{ row.date }}</td>
92
+ </tr>
93
+ {% endfor %}
94
+ </tbody>
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>
104
+ {% endblock %}
api/app/templates/pages/monitor.html ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% from "macros/ui.html" import ms_icon, health_chip, info_row, loaded_badge %}
3
+
4
+ {% block title %}System Monitor{% endblock %}
5
+ {% block description %}Real-time API health, model metadata, and request statistics.{% endblock %}
6
+
7
+ {% block content %}
8
+ {#
9
+ Phase 2: Placeholder layout for the System Monitor page.
10
+
11
+ What this page will contain after Phase 5:
12
+ - API Status card : live health chip polled via hx-trigger="every 30s"
13
+ - Model Configuration : architecture, languages, model status
14
+ - Performance Metrics : stat cards with SVG sparklines
15
+ - Recent Activity table : last N API requests
16
+
17
+ All section structures below match Monitor.jsx exactly so Phase 5 only needs to
18
+ add live data and HTMX polling attributes.
19
+ #}
20
+ <div class="space-y-xl">
21
+
22
+ {# ── Page header ─────────────────────────────────────────────────────────── #}
23
+ <div class="flex flex-wrap justify-between items-end gap-4">
24
+ <div>
25
+ <h1 class="text-headline-md text-[#dae2fd]">System Monitor</h1>
26
+ <p class="mt-1 text-body-md text-[#c7c4d7]">
27
+ Real-time API health, model metadata, and request statistics.
28
+ </p>
29
+ </div>
30
+ <div class="flex items-center gap-2">
31
+ <label for="refresh-select" class="font-mono text-label-sm text-[#c7c4d7]">
32
+ Auto-refresh
33
+ </label>
34
+ <select id="refresh-select" class="input-base py-1 text-body-sm" style="width:auto;">
35
+ <option value="10000">10s</option>
36
+ <option value="30000" selected>30s</option>
37
+ <option value="60000">1m</option>
38
+ <option value="0">Off</option>
39
+ </select>
40
+ </div>
41
+ </div>
42
+
43
+ {# ── Health + Model config ────────────────────────────────────────────────── #}
44
+ <div class="grid grid-cols-1 lg:grid-cols-12 gap-xl">
45
+
46
+ {# API Status card #}
47
+ <div class="lg:col-span-4 card flex flex-col gap-lg">
48
+ <div class="flex items-center gap-3">
49
+ <div class="p-2.5 rounded-lg bg-[#4edea3]/10">
50
+ {{ ms_icon('monitor_heart', size=24, cls='text-[#4edea3]') }}
51
+ </div>
52
+ <div>
53
+ <h2 class="text-title-lg text-[#dae2fd]">API Status</h2>
54
+ <p class="font-mono text-label-sm text-[#c7c4d7]">Core Inference Engine</p>
55
+ </div>
56
+ </div>
57
+
58
+ {# Health status — HTMX polling target in Phase 5 #}
59
+ <div id="health-status"
60
+ class="flex items-center gap-3 pt-lg border-t border-white/[0.06]">
61
+ <span class="text-body-md text-[#c7c4d7]">Current state:</span>
62
+ {# Static placeholder — replaced by live fragment in Phase 5 #}
63
+ {{ health_chip(True) }}
64
+ </div>
65
+ </div>
66
+
67
+ {# Model Configuration card #}
68
+ <div class="lg:col-span-8 card">
69
+ <div class="flex items-center gap-3 mb-xl">
70
+ <div class="p-2.5 rounded-lg bg-[#c0c1ff]/10">
71
+ {{ ms_icon('memory', size=24, cls='text-[#c0c1ff]') }}
72
+ </div>
73
+ <div>
74
+ <h2 class="text-title-lg text-[#dae2fd]">Model Configuration</h2>
75
+ <p class="font-mono text-label-sm text-[#c7c4d7]">Loaded ONNX Graphs</p>
76
+ </div>
77
+ </div>
78
+ <div class="grid grid-cols-2 gap-md">
79
+ {{ info_row('Architecture', 'XLM-RoBERTa (INT8)') }}
80
+ {{ info_row('Supported Languages', 'English, Hindi, Hinglish') }}
81
+ <div class="bg-[#0b1326] rounded-lg p-3">
82
+ <p class="font-mono text-[11px] text-[#c7c4d7] mb-1 uppercase tracking-wide">Aspect Extraction</p>
83
+ {{ loaded_badge() }}
84
+ </div>
85
+ <div class="bg-[#0b1326] rounded-lg p-3">
86
+ <p class="font-mono text-[11px] text-[#c7c4d7] mb-1 uppercase tracking-wide">Sentiment Classification</p>
87
+ {{ loaded_badge() }}
88
+ </div>
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ {# ── Performance metrics (stat cards) ─────────────────────────────────────── #}
94
+ <div>
95
+ <h2 class="text-headline-sm text-[#dae2fd] mb-lg">Performance Metrics</h2>
96
+ <div class="grid grid-cols-1 sm:grid-cols-3 gap-xl">
97
+
98
+ {# Macro-style stat card — defined inline since it's used only here #}
99
+ {% for stat in [
100
+ {'icon': 'database', 'label': 'Total Requests Today', 'value': '12.4k', 'sub': '↑ 8% vs yesterday', 'color': '#c0c1ff', 'positive': true},
101
+ {'icon': 'bolt', 'label': 'Avg Latency (P95)', 'value': '145ms', 'sub': 'Well within SLA', 'color': '#4edea3', 'positive': true},
102
+ {'icon': 'warning', 'label': 'Error Rate', 'value': '0.2%', 'sub': 'Last 24 hours', 'color': '#ffb4ab', 'positive': false},
103
+ ] %}
104
+ <div class="stat-card group">
105
+ {# Sparkline gradient background #}
106
+ <div class="absolute bottom-0 left-0 w-full h-16 opacity-40 group-hover:opacity-70
107
+ transition-opacity pointer-events-none">
108
+ <svg viewBox="0 0 100 40" class="w-full h-full" preserveAspectRatio="none" aria-hidden="true">
109
+ <path d="{{ 'M0 40 L0 28 Q25 24 50 20 T100 14 L100 40 Z' if stat.positive else 'M0 40 L0 32 Q25 30 50 26 T100 22 L100 40 Z' }}"
110
+ fill="{{ stat.color }}22"
111
+ stroke="{{ stat.color }}"
112
+ stroke-width="1.5"
113
+ vector-effect="non-scaling-stroke" />
114
+ </svg>
115
+ </div>
116
+ <div class="relative z-10">
117
+ <div class="flex items-center gap-2 mb-lg">
118
+ <span class="material-symbols-outlined text-2xl"
119
+ style="color: {{ stat.color }}; font-variation-settings: 'FILL' 1, 'wght' 400;"
120
+ aria-hidden="true">{{ stat.icon }}</span>
121
+ <span class="font-mono text-label-md text-[#c7c4d7] uppercase tracking-wider">{{ stat.label }}</span>
122
+ </div>
123
+ <p class="text-display text-[#dae2fd] font-semibold">{{ stat.value }}</p>
124
+ <p class="font-mono text-label-sm text-[#c7c4d7] mt-1">{{ stat.sub }}</p>
125
+ </div>
126
+ </div>
127
+ {% endfor %}
128
+
129
+ </div>
130
+ </div>
131
+
132
+ {# ── Recent endpoint activity ──────────────────────────────────────────────── #}
133
+ <div class="card">
134
+ <h2 class="text-title-lg text-[#dae2fd] mb-lg">Recent Endpoint Activity</h2>
135
+ <div class="space-y-2" role="list" aria-label="Recent API requests">
136
+ {% for req in [
137
+ {'method': 'POST', 'path': '/predict', 'status': 200, 'time': '3.5ms', 'ago': '2s ago'},
138
+ {'method': 'GET', 'path': '/health', 'status': 200, 'time': '0.8ms', 'ago': '5s ago'},
139
+ {'method': 'POST', 'path': '/batch', 'status': 202, 'time': '12.1ms', 'ago': '1m ago'},
140
+ {'method': 'GET', 'path': '/status/abc12', 'status': 200, 'time': '1.2ms', 'ago': '1m ago'},
141
+ {'method': 'POST', 'path': '/predict', 'status': 500, 'time': '23ms', 'ago': '3m ago'},
142
+ ] %}
143
+ <div class="flex items-center gap-4 py-2 px-md rounded-lg hover:bg-white/[0.03] transition-colors"
144
+ role="listitem">
145
+ <span class="font-mono text-label-sm w-10 flex-shrink-0
146
+ {{ 'text-[#c0c1ff]' if req.method == 'POST' else 'text-[#4edea3]' }}">
147
+ {{ req.method }}
148
+ </span>
149
+ <span class="font-mono text-body-sm text-[#dae2fd] flex-1 truncate">{{ req.path }}</span>
150
+ <span class="font-mono text-label-sm w-10 text-right flex-shrink-0
151
+ {{ 'text-[#ffb4ab]' if req.status >= 500 else ('text-[#f5c542]' if req.status >= 400 else 'text-[#4edea3]') }}">
152
+ {{ req.status }}
153
+ </span>
154
+ <span class="font-mono text-label-sm text-[#c7c4d7] w-14 text-right flex-shrink-0">{{ req.time }}</span>
155
+ <span class="hidden sm:block font-mono text-label-sm text-[#c7c4d7]/50 w-16 text-right flex-shrink-0">
156
+ {{ req.ago }}
157
+ </span>
158
+ </div>
159
+ {% endfor %}
160
+ </div>
161
+
162
+ <p class="mt-4 font-mono text-label-sm text-[#c7c4d7]/50 text-center">
163
+ Phase 5 will add live polling from <code class="text-[#c0c1ff]">GET /health</code>.
164
+ </p>
165
+ </div>
166
+
167
+ </div>
168
+ {% endblock %}
api/app/templates/pages/predict.html ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% from "macros/ui.html" import ms_icon, empty_state %}
3
+
4
+ {% block title %}Live Predictor{% endblock %}
5
+ {% block description %}Analyze aspect-based sentiment in English and Hindi text in real time.{% endblock %}
6
+
7
+ {% block content %}
8
+ {#
9
+ Phase 2: Placeholder layout for the Predict page.
10
+
11
+ What this page will contain after Phase 3:
12
+ - Left panel : textarea input + language selector + Analyze button
13
+ - Right panel : annotated result text + aspect cards (HTMX swap target)
14
+ - POST /predict/fragment → returns partials/predict_result.html
15
+
16
+ The outer grid, headings, and card shells are already correct here so Phase 3
17
+ only needs to fill in the form and wire up HTMX — no structural changes.
18
+ #}
19
+ <div class="space-y-xl">
20
+
21
+ {# ── Page header ─────────────────────────────────────────────────────────── #}
22
+ <div>
23
+ <h1 class="text-headline-md text-[#dae2fd]">Live Sentiment Predictor</h1>
24
+ <p class="mt-1 text-body-md text-[#c7c4d7] max-w-2xl">
25
+ Enter text to analyze its aspects and sentiments in real-time.
26
+ The model automatically identifies the language and extracts key phrases.
27
+ </p>
28
+ </div>
29
+
30
+ {# ── Two-column layout (mirrors LivePredictor.jsx structure) ─────────────── #}
31
+ <div class="grid grid-cols-1 lg:grid-cols-12 gap-xl">
32
+
33
+ {# ── Left: Input panel ───────────────────────────────────────────────── #}
34
+ <div class="lg:col-span-7 flex flex-col">
35
+ <div class="card-low flex flex-col h-full min-h-[420px]">
36
+
37
+ <div class="flex justify-between items-center mb-lg pb-md border-b border-white/[0.06]">
38
+ <h3 class="font-mono text-label-md text-[#c7c4d7] uppercase tracking-wider">
39
+ Analyze Input
40
+ </h3>
41
+ {# Language selector placeholder — wired up in Phase 3 #}
42
+ <div class="flex items-center gap-2">
43
+ <label for="lang-select-preview"
44
+ class="font-mono text-label-sm text-[#c7c4d7]">Language</label>
45
+ <select id="lang-select-preview" class="input-base py-1 text-body-sm" style="width:auto;" disabled>
46
+ <option>Auto-detect</option>
47
+ </select>
48
+ </div>
49
+ </div>
50
+
51
+ {# Textarea placeholder #}
52
+ <div class="flex-1 flex flex-col mb-lg">
53
+ <label for="review-text-preview"
54
+ class="font-mono text-label-sm text-[#c7c4d7] mb-2">Source Text</label>
55
+ <textarea id="review-text-preview"
56
+ class="input-base flex-1 min-h-[260px] resize-none leading-relaxed"
57
+ placeholder="Paste your review, article, or social media post here…"
58
+ disabled>
59
+ </textarea>
60
+ <div class="flex justify-between mt-2">
61
+ <span class="font-mono text-label-sm text-[#c7c4d7]/50">⌘ Enter to analyze</span>
62
+ <span class="font-mono text-label-sm text-[#c7c4d7]/50">0 / 512</span>
63
+ </div>
64
+ </div>
65
+
66
+ {# Analyze button placeholder #}
67
+ <button class="btn-primary" disabled>
68
+ {{ ms_icon('bolt', size=16) }}
69
+ Analyze
70
+ </button>
71
+
72
+ </div>
73
+ </div>
74
+
75
+ {# ── Right: Results panel ─────────────────────────────────────────────── #}
76
+ <div class="lg:col-span-5 flex flex-col">
77
+ <div class="glass-panel rounded-xl flex flex-col h-full min-h-[420px] p-xl">
78
+
79
+ <div class="flex justify-between items-center mb-lg pb-md border-b border-white/[0.06]">
80
+ <h3 class="font-mono text-label-md text-[#c7c4d7] uppercase tracking-wider">
81
+ Analysis Results
82
+ </h3>
83
+ </div>
84
+
85
+ {# Phase 2 placeholder — replaced by HTMX partial in Phase 3 #}
86
+ <div id="result-panel" class="flex-1 flex">
87
+ {{ empty_state('psychology', 'Enter a review and click Analyze to see results') }}
88
+ </div>
89
+
90
+ </div>
91
+ </div>
92
+
93
+ </div>{# /grid #}
94
+
95
+ {# Phase 2 notice (removed in Phase 3) #}
96
+ <div class="card border-[#c0c1ff]/20 bg-[#c0c1ff]/5">
97
+ <div class="flex items-start gap-3">
98
+ {{ ms_icon('info', size=18, cls='text-[#c0c1ff] mt-0.5 flex-shrink-0') }}
99
+ <div>
100
+ <p class="text-body-md text-[#dae2fd] font-medium mb-1">Phase 2 Infrastructure</p>
101
+ <p class="text-body-sm text-[#c7c4d7]">
102
+ The layout shell is in place. The
103
+ <code class="font-mono text-[#c0c1ff] text-xs">POST /predict</code>
104
+ API endpoint is fully operational — HTMX interactions will be wired in Phase 3.
105
+ <a href="/docs#/Predict/predict_predict_post"
106
+ target="_blank"
107
+ class="text-[#c0c1ff] hover:underline ml-1">Test the API directly →</a>
108
+ </p>
109
+ </div>
110
+ </div>
111
+ </div>
112
+
113
+ </div>
114
+ {% endblock %}
requirements.txt CHANGED
@@ -15,6 +15,7 @@ mlflow==2.13.0
15
  dvc==3.51.1
16
  evidently==0.4.30
17
  fastapi==0.111.0
 
18
  uvicorn==0.29.0
19
  celery==5.4.0
20
  redis==5.0.4
 
15
  dvc==3.51.1
16
  evidently==0.4.30
17
  fastapi==0.111.0
18
+ jinja2>=3.1.4
19
  uvicorn==0.29.0
20
  celery==5.4.0
21
  redis==5.0.4
tests/api/test_api.py CHANGED
@@ -52,3 +52,20 @@ def test_batch_upload():
52
  assert data["status"] == "queued"
53
  assert data["total_reviews"] == 2
54
  mock_delay.assert_called_once()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
52
  assert data["status"] == "queued"
53
  assert data["total_reviews"] == 2
54
  mock_delay.assert_called_once()
55
+
56
+ def test_info_endpoint():
57
+ with TestClient(app) as client:
58
+ response = client.get("/info")
59
+ assert response.status_code == 200
60
+ data = response.json()
61
+ assert "model_name" in data
62
+ assert "supported_languages" in data
63
+ assert isinstance(data["supported_languages"], str)
64
+
65
+ def test_metrics_endpoint():
66
+ with TestClient(app) as client:
67
+ response = client.get("/metrics")
68
+ assert response.status_code == 200
69
+ # metrics returns plain text Prometheus data
70
+ assert "text/plain" in response.headers["content-type"]
71
+ assert "http_requests_total" in response.text
tests/web/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Phase 2 test package
tests/web/test_pages.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ tests/web/test_pages.py — Phase 2 smoke tests.
3
+
4
+ PURPOSE
5
+ -------
6
+ Verify that the new Jinja2 page infrastructure:
7
+ 1. Renders all three pages without raising an exception (HTTP 200).
8
+ 2. Returns HTML content (not JSON).
9
+ 3. Contains the expected page titles / identifiers.
10
+ 4. Does NOT break any existing JSON API endpoint.
11
+ 5. Serves static CSS from the /static mount.
12
+
13
+ These tests are strictly additive — they do not modify or replace
14
+ any tests in tests/api/test_api.py.
15
+
16
+ IMPORTANT: DATABASE_URL must be set before importing the app because
17
+ api/app/middleware/dependencies.py reads it at module-import time.
18
+ """
19
+ import os
20
+
21
+ # Must be set before any app import (same pattern as tests/api/test_api.py)
22
+ os.environ.setdefault("DATABASE_URL", "sqlite:///./tests/fixtures/test.db")
23
+
24
+ import pytest
25
+ from fastapi.testclient import TestClient
26
+
27
+ from api.app.main import app
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Helpers
32
+ # ---------------------------------------------------------------------------
33
+
34
+ def _html_client() -> TestClient:
35
+ """Return a TestClient that triggers the lifespan (model loading)."""
36
+ return TestClient(app)
37
+
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Page rendering tests
41
+ # ---------------------------------------------------------------------------
42
+
43
+ class TestPageRoutes:
44
+ """Smoke tests: every GET page route returns 200 HTML."""
45
+
46
+ def test_root_redirects_to_predict(self):
47
+ """GET / should return the Predict page (200, HTML)."""
48
+ with _html_client() as client:
49
+ response = client.get("/", follow_redirects=True)
50
+ assert response.status_code == 200
51
+ assert "text/html" in response.headers["content-type"]
52
+
53
+ def test_predict_page_renders(self):
54
+ with _html_client() as client:
55
+ response = client.get("/predict")
56
+ assert response.status_code == 200
57
+ assert "text/html" in response.headers["content-type"]
58
+ assert "SentimentAI" in response.text
59
+ assert "Live Predictor" in response.text
60
+
61
+ def test_batch_page_renders(self):
62
+ with _html_client() as client:
63
+ response = client.get("/batch")
64
+ assert response.status_code == 200
65
+ assert "text/html" in response.headers["content-type"]
66
+ assert "SentimentAI" in response.text
67
+ assert "Batch Analytics" in response.text
68
+
69
+ def test_monitor_page_renders(self):
70
+ with _html_client() as client:
71
+ response = client.get("/monitor")
72
+ assert response.status_code == 200
73
+ assert "text/html" in response.headers["content-type"]
74
+ assert "SentimentAI" in response.text
75
+ assert "System Monitor" in response.text
76
+
77
+ def test_sidebar_nav_items_present(self):
78
+ """All three nav links must appear in every page."""
79
+ with _html_client() as client:
80
+ for path in ("/predict", "/batch", "/monitor"):
81
+ response = client.get(path)
82
+ assert response.status_code == 200
83
+ # Check all nav labels are present
84
+ assert "Predictor" in response.text
85
+ assert "Batch Analytics" in response.text
86
+ assert "System Health" in response.text
87
+
88
+ def test_predict_active_state(self):
89
+ """/predict page must mark Predictor nav item as active."""
90
+ with _html_client() as client:
91
+ response = client.get("/predict")
92
+ assert "nav-item-active" in response.text
93
+ # The active item must contain the predictor icon
94
+ assert "psychology" in response.text
95
+
96
+ def test_base_template_includes_htmx(self):
97
+ """HTMX CDN script must be present in every page."""
98
+ with _html_client() as client:
99
+ response = client.get("/predict")
100
+ assert "htmx.org" in response.text
101
+
102
+ def test_base_template_includes_alpinejs(self):
103
+ """Alpine.js CDN script must be present in every page."""
104
+ with _html_client() as client:
105
+ response = client.get("/predict")
106
+ assert "alpinejs" in response.text
107
+
108
+ def test_base_template_includes_tailwind(self):
109
+ """Tailwind CDN script must be present in every page."""
110
+ with _html_client() as client:
111
+ response = client.get("/predict")
112
+ assert "cdn.tailwindcss.com" in response.text
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Static file tests
117
+ # ---------------------------------------------------------------------------
118
+
119
+ class TestStaticFiles:
120
+ """Verify the /static mount serves files correctly."""
121
+
122
+ def test_css_file_served(self):
123
+ with _html_client() as client:
124
+ response = client.get("/static/css/app.css")
125
+ assert response.status_code == 200
126
+ assert "text/css" in response.headers["content-type"]
127
+ # Spot-check for key design system classes
128
+ assert "badge-positive" in response.text
129
+ assert "btn-primary" in response.text
130
+
131
+ def test_static_missing_file_returns_404(self):
132
+ with _html_client() as client:
133
+ response = client.get("/static/does-not-exist.css")
134
+ assert response.status_code == 404
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Regression tests — existing API must be unaffected
139
+ # ---------------------------------------------------------------------------
140
+
141
+ class TestExistingAPIUnchanged:
142
+ """
143
+ Re-run the core API assertions to prove Phase 2 changes introduced
144
+ zero regressions. These mirror tests/api/test_api.py in spirit.
145
+ """
146
+
147
+ def test_health_endpoint_still_returns_json(self):
148
+ with _html_client() as client:
149
+ response = client.get("/health")
150
+ assert response.status_code == 200
151
+ data = response.json()
152
+ assert data["status"] == "ok"
153
+ # Verify content-type is JSON (not HTML)
154
+ assert "application/json" in response.headers["content-type"]
155
+
156
+ def test_info_endpoint_still_returns_json(self):
157
+ with _html_client() as client:
158
+ response = client.get("/info")
159
+ assert response.status_code == 200
160
+ data = response.json()
161
+ assert "model_name" in data
162
+ assert "supported_languages" in data
163
+
164
+ def test_predict_endpoint_still_returns_json(self):
165
+ """POST /predict must still return PredictionResponse JSON."""
166
+ with _html_client() as client:
167
+ response = client.post(
168
+ "/predict",
169
+ json={"text": "The sound quality is excellent.", "language": "en"},
170
+ )
171
+ assert response.status_code == 200
172
+ data = response.json()
173
+ # Schema check — all required fields must be present
174
+ assert "text" in data
175
+ assert "language" in data
176
+ assert "detected_language" in data
177
+ assert "aspects" in data
178
+ assert "processing_time_ms" in data
179
+ # Content-type must be JSON
180
+ assert "application/json" in response.headers["content-type"]
181
+
182
+ def test_openapi_schema_page_routes_hidden(self):
183
+ """Page GET routes must NOT appear in the OpenAPI schema."""
184
+ with _html_client() as client:
185
+ response = client.get("/openapi.json")
186
+ assert response.status_code == 200
187
+ schema = response.json()
188
+ paths = schema.get("paths", {})
189
+ # None of the page routes should be in the schema
190
+ assert "/predict" not in paths or all(
191
+ method == "post" for method in paths.get("/predict", {})
192
+ ), "GET /predict should not appear in OpenAPI schema"
193
+ assert "/" not in paths, "GET / should not appear in OpenAPI schema"
194
+ assert "/batch" not in paths or all(
195
+ method == "post" for method in paths.get("/batch", {})
196
+ ), "GET /batch should not appear in OpenAPI schema"
197
+ assert "/monitor" not in paths, "GET /monitor should not appear in OpenAPI schema"