Aryan Mishra commited on
Commit
7535e76
·
1 Parent(s): 547bc5b

Restructure to api/src and add Streamlit UI

Browse files

Refactors the project layout by moving backend modules from `app` to `api`, moving ML code under `src/absa`, and updating imports, Docker entrypoints, DVC commands, and scripts to match. Replaces the HTMX/Jinja web layer with a new Streamlit frontend (`frontend/`) including analyzer, admin overview, batch, and monitor views backed by an HTTP API client. Also consolidates dependencies and tooling in `pyproject.toml`, adds a Makefile workflow, and updates tests (new Streamlit/client and pipeline tests, removed legacy web page tests).

The pipeline rule-based fallback was strengthened with clause-aware scoring, Unicode-aware tokenization for Devanagari, broader sentiment lexicons, noun-near-sentiment target extraction, and an overall fallback aspect so predictions remain useful when lexicon matches are missing.

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. Makefile +46 -0
  2. {absa → api}/__init__.py +0 -0
  3. {absa/data → api/core}/__init__.py +0 -0
  4. {app → api}/main.py +11 -28
  5. {absa/evaluation → api/middleware}/__init__.py +0 -0
  6. {app → api}/middleware/dependencies.py +0 -0
  7. {app → api}/middleware/metrics.py +0 -0
  8. {absa/models → api/routes}/__init__.py +0 -0
  9. {app → api}/routes/predict.py +5 -5
  10. {app → api}/routes/results.py +0 -0
  11. {absa/training → api/schemas}/__init__.py +0 -0
  12. {app → api}/schemas/db_models.py +0 -0
  13. {app → api}/schemas/schemas.py +0 -0
  14. {absa/utils → api/services}/__init__.py +0 -0
  15. {app → api}/services/absa_pipeline.py +220 -29
  16. {app → api}/services/lang_service.py +0 -0
  17. {app → api}/tasks/__init__.py +0 -0
  18. {app → api}/tasks/batch_tasks.py +4 -4
  19. app/core/templates.py +0 -36
  20. app/middleware/csrf.py +0 -59
  21. app/routes/pages.py +0 -327
  22. app/static/css/app.css +0 -497
  23. app/templates/base.html +0 -449
  24. app/templates/macros/ui.html +0 -178
  25. app/templates/pages/batch.html +0 -124
  26. app/templates/pages/monitor.html +0 -161
  27. app/templates/pages/predict.html +0 -107
  28. app/templates/partials/batch_charts.html +0 -114
  29. app/templates/partials/batch_progress.html +0 -51
  30. app/templates/partials/monitor_health.html +0 -19
  31. app/templates/partials/predict_result.html +0 -101
  32. docker/Dockerfile +9 -5
  33. docker/Dockerfile.prod +8 -6
  34. docker/docker-compose.yml +1 -1
  35. docs/HTMX_MIGRATION.md +0 -92
  36. dvc.yaml +10 -10
  37. frontend/Home.py +57 -0
  38. {app → frontend}/__init__.py +0 -0
  39. frontend/absa_client.py +84 -0
  40. frontend/ui.py +251 -0
  41. frontend/views/admin/batch.py +118 -0
  42. frontend/views/admin/monitor.py +53 -0
  43. frontend/views/admin/overview.py +98 -0
  44. frontend/views/predict.py +67 -0
  45. {ml/notebooks → notebooks}/01_data_exploration.ipynb +0 -0
  46. {ml/notebooks → notebooks}/03_model_comparison.ipynb +0 -0
  47. {ml/notebooks → notebooks}/03_train_colab.ipynb +0 -0
  48. {ml/notebooks → notebooks}/04_qlora_colab.ipynb +0 -0
  49. {ml/notebooks → notebooks}/08_final_evaluation.ipynb +0 -0
  50. pyproject.toml +82 -3
Makefile ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .PHONY: install dev api frontend worker test lint typecheck security coverage docker-up docker-down clean
2
+
3
+ # ── Setup ────────────────────────────────────────────────────────────────
4
+ install:
5
+ pip install .
6
+
7
+ dev:
8
+ pip install -e ".[dev]"
9
+
10
+ # ── Services ─────────────────────────────────────────────────────────────
11
+ api:
12
+ uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload
13
+
14
+ frontend:
15
+ streamlit run frontend/Home.py --server.port 8501
16
+
17
+ worker:
18
+ celery -A api.tasks worker --loglevel=info
19
+
20
+ # ── Quality gates ────────────────────────────────────────────────────────
21
+ test:
22
+ PYTHONPATH=src pytest
23
+
24
+ lint:
25
+ PYTHONPATH=src ruff check api src/absa tests
26
+
27
+ typecheck:
28
+ PYTHONPATH=src mypy api src/absa
29
+
30
+ security:
31
+ bandit -r api src/absa
32
+
33
+ coverage:
34
+ PYTHONPATH=src pytest --cov=api --cov=absa --cov-report=term-missing
35
+
36
+ # ── Docker ───────────────────────────────────────────────────────────────
37
+ docker-up:
38
+ docker compose -f docker/docker-compose.yml up -d
39
+
40
+ docker-down:
41
+ docker compose -f docker/docker-compose.yml down
42
+
43
+ # ── Cleanup ──────────────────────────────────────────────────────────────
44
+ clean:
45
+ find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true
46
+ rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage coverage.xml htmlcov
{absa → api}/__init__.py RENAMED
File without changes
{absa/data → api/core}/__init__.py RENAMED
File without changes
{app → api}/main.py RENAMED
@@ -1,23 +1,20 @@
1
- from fastapi import FastAPI
2
- from fastapi.middleware.cors import CORSMiddleware
3
  from contextlib import asynccontextmanager
 
4
  from dotenv import load_dotenv
 
 
5
  from slowapi import Limiter, _rate_limit_exceeded_handler
6
- from slowapi.util import get_remote_address
7
  from slowapi.errors import RateLimitExceeded
8
-
9
- import os
10
- from fastapi.staticfiles import StaticFiles
11
- from pathlib import Path
12
 
13
  load_dotenv()
14
 
15
- from app.routes import predict, results # noqa: E402
16
- from app.routes import pages # noqa: E402 Phase 2: Jinja2 page routes
17
- from app.middleware.metrics import instrumentator # noqa: E402
18
- from app.services.absa_pipeline import pipeline # noqa: E402
19
- from app.schemas.db_models import Base # noqa: E402
20
- from app.middleware.dependencies import engine # noqa: E402
21
 
22
 
23
  @asynccontextmanager
@@ -46,28 +43,14 @@ app = FastAPI(
46
  app.state.limiter = limiter
47
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
48
 
49
- from app.middleware.csrf import CSRFMiddleware # noqa: E402
50
-
51
  app.add_middleware(
52
  CORSMiddleware,
53
- allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:8000").split(","),
54
  allow_credentials=True,
55
  allow_methods=["GET", "POST"],
56
  allow_headers=["*"],
57
  )
58
- app.add_middleware(CSRFMiddleware) # Skips /api/* routes; protects HTMX form endpoints
59
  app.include_router(predict.router, tags=["Predict"])
60
  app.include_router(results.router, tags=["System"])
61
 
62
  instrumentator.instrument(app).expose(app, endpoint="/metrics")
63
-
64
- # Jinja2 / HTMX frontend routing and static files
65
- # Note: StaticFiles is mounted after instrumentator so prometheus
66
- # ignores it for /metrics, although this might still log /static requests.
67
-
68
- app.include_router(pages.router) # include_in_schema=False is set on the router itself
69
-
70
- # Resolve path relative to this file so it works regardless of CWD.
71
- _STATIC_DIR = Path(__file__).parent / "static"
72
- _STATIC_DIR.mkdir(parents=True, exist_ok=True) # idempotent safety guard
73
- app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
 
1
+ import os
 
2
  from contextlib import asynccontextmanager
3
+
4
  from dotenv import load_dotenv
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
  from slowapi import Limiter, _rate_limit_exceeded_handler
 
8
  from slowapi.errors import RateLimitExceeded
9
+ from slowapi.util import get_remote_address
 
 
 
10
 
11
  load_dotenv()
12
 
13
+ from api.middleware.dependencies import engine # noqa: E402
14
+ from api.middleware.metrics import instrumentator # noqa: E402
15
+ from api.routes import predict, results # noqa: E402
16
+ from api.schemas.db_models import Base # noqa: E402
17
+ from api.services.absa_pipeline import pipeline # noqa: E402
 
18
 
19
 
20
  @asynccontextmanager
 
43
  app.state.limiter = limiter
44
  app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
45
 
 
 
46
  app.add_middleware(
47
  CORSMiddleware,
48
+ allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:8000,http://localhost:8501").split(","),
49
  allow_credentials=True,
50
  allow_methods=["GET", "POST"],
51
  allow_headers=["*"],
52
  )
 
53
  app.include_router(predict.router, tags=["Predict"])
54
  app.include_router(results.router, tags=["System"])
55
 
56
  instrumentator.instrument(app).expose(app, endpoint="/metrics")
 
 
 
 
 
 
 
 
 
 
 
{absa/evaluation → api/middleware}/__init__.py RENAMED
File without changes
{app → api}/middleware/dependencies.py RENAMED
File without changes
{app → api}/middleware/metrics.py RENAMED
File without changes
{absa/models → api/routes}/__init__.py RENAMED
File without changes
{app → api}/routes/predict.py RENAMED
@@ -7,11 +7,11 @@ import tempfile
7
  import time
8
  import re
9
 
10
- from app.schemas.schemas import ReviewInput, PredictionResponse, BatchJobResponse
11
- from app.schemas.db_models import Review, AspectResult, BatchJob
12
- from app.middleware.dependencies import get_db
13
- from app.services.absa_pipeline import pipeline
14
- from app.tasks.batch_tasks import process_batch
15
  router = APIRouter()
16
 
17
 
 
7
  import time
8
  import re
9
 
10
+ from api.schemas.schemas import ReviewInput, PredictionResponse, BatchJobResponse
11
+ from api.schemas.db_models import Review, AspectResult, BatchJob
12
+ from api.middleware.dependencies import get_db
13
+ from api.services.absa_pipeline import pipeline
14
+ from api.tasks.batch_tasks import process_batch
15
  router = APIRouter()
16
 
17
 
{app → api}/routes/results.py RENAMED
File without changes
{absa/training → api/schemas}/__init__.py RENAMED
File without changes
{app → api}/schemas/db_models.py RENAMED
File without changes
{app → api}/schemas/schemas.py RENAMED
File without changes
{absa/utils → api/services}/__init__.py RENAMED
File without changes
{app → api}/services/absa_pipeline.py RENAMED
@@ -12,20 +12,21 @@ Strategy:
12
 
13
  import os
14
  import re
15
- import time
16
  import threading
 
17
  from pathlib import Path
18
- from typing import List, Tuple
 
19
  import numpy as np
20
 
21
- from app.schemas.schemas import PredictionResponse, AspectSentiment
22
- from app.services.lang_service import lang_service
23
 
24
  # ── Optional heavy imports (ONNX custom models) ───────────────────────────────
25
  try:
26
  from optimum.onnxruntime import (
27
- ORTModelForTokenClassification,
28
  ORTModelForSequenceClassification,
 
29
  )
30
  from transformers import AutoTokenizer
31
 
@@ -143,6 +144,114 @@ ASPECT_PHRASES: List[str] = sorted(
143
  reverse=True,
144
  )
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  # ── Sentiment lexicon ─────────────────────────────────────────────────────────
147
  POSITIVE_WORDS = {
148
  "excellent",
@@ -182,6 +291,11 @@ POSITIVE_WORDS = {
182
  "recommended",
183
  "worth",
184
  "affordable",
 
 
 
 
 
185
  "value",
186
  "effective",
187
  "efficient",
@@ -215,6 +329,18 @@ POSITIVE_WORDS = {
215
  "shandar",
216
  "zabardast",
217
  "mast",
 
 
 
 
 
 
 
 
 
 
 
 
218
  }
219
 
220
  NEGATIVE_WORDS = {
@@ -271,6 +397,10 @@ NEGATIVE_WORDS = {
271
  "broken",
272
  "defective",
273
  "faulty",
 
 
 
 
274
  "average",
275
  "ordinary",
276
  "basic",
@@ -281,6 +411,14 @@ NEGATIVE_WORDS = {
281
  "bura",
282
  "ganda",
283
  "faltu",
 
 
 
 
 
 
 
 
284
  }
285
 
286
  NEGATION_WORDS = {
@@ -322,12 +460,34 @@ INTENSIFIERS = {
322
  }
323
 
324
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  def _score_sentence(sentence: str) -> Tuple[float, float]:
326
  """
327
  Return (positive_score, negative_score) for a sentence.
328
  Handles negation (3-word window) and intensifiers.
329
  """
330
- words = re.findall(r"\b[\w'-]+\b", sentence.lower())
331
  pos, neg = 0.0, 0.0
332
  i = 0
333
  while i < len(words):
@@ -399,10 +559,8 @@ class ABSAPipeline:
399
  self.aspect_model = ORTModelForTokenClassification.from_pretrained(
400
  hf_repo_id, subfolder="aspect_extraction_int8"
401
  )
402
- self.sentiment_model = (
403
- ORTModelForSequenceClassification.from_pretrained(
404
- hf_repo_id, subfolder="sentiment_int8"
405
- )
406
  )
407
  print("Custom ONNX models loaded.")
408
  except Exception as e:
@@ -420,14 +578,8 @@ class ABSAPipeline:
420
  try:
421
  print(f"Loading custom ONNX models from {model_path_base}")
422
  self.tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
423
- self.aspect_model = ORTModelForTokenClassification.from_pretrained(
424
- str(aspect_path)
425
- )
426
- self.sentiment_model = (
427
- ORTModelForSequenceClassification.from_pretrained(
428
- str(sentiment_path)
429
- )
430
- )
431
  print("Custom ONNX models loaded.")
432
  except Exception as e:
433
  print(f"Custom model load skipped: {e}")
@@ -437,7 +589,7 @@ class ABSAPipeline:
437
 
438
  self.is_loaded = True
439
 
440
- def predict(self, text: str, requested_lang: str = None) -> PredictionResponse:
441
  start = time.time()
442
  detected_lang = lang_service.detect_language(text)
443
  actual_lang = requested_lang or detected_lang
@@ -461,14 +613,17 @@ class ABSAPipeline:
461
  # ── Custom ONNX path ──────────────────────────────────────────────────────
462
 
463
  def _predict_onnx(self, text: str) -> List[AspectSentiment]:
464
- inputs = self.tokenizer(
465
- text, return_tensors="pt", truncation=True, max_length=128
466
- )
 
467
  logits = self.aspect_model(**inputs).logits[0].detach().numpy()
468
  preds = np.argmax(logits, axis=1)
469
  tokens = self.tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
470
 
471
- raw, current, start_idx = [], [], -1
 
 
472
  skip = {
473
  self.tokenizer.cls_token,
474
  self.tokenizer.sep_token,
@@ -519,17 +674,26 @@ class ABSAPipeline:
519
 
520
  def _predict_rule_based(self, text: str) -> List[AspectSentiment]:
521
  text_lower = text.lower()
522
- sentences = re.split(r"(?<=[.!?])\s+", text)
523
  found_aspects = self._extract_aspects(text_lower)
524
 
 
 
 
 
 
 
 
 
 
525
  results = []
526
  for aspect_label, start_char, end_char in found_aspects:
527
- # Find the sentence(s) mentioning this aspect for focused scoring
 
 
528
  aspect_lower = aspect_label.lower()
529
- context_sentences = [s for s in sentences if aspect_lower in s.lower()] or [
530
- text
531
- ]
532
- context = " ".join(context_sentences)
533
 
534
  pos, neg = _score_sentence(context)
535
 
@@ -550,6 +714,33 @@ class ABSAPipeline:
550
  )
551
  return results
552
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
553
  def _extract_aspects(self, text_lower: str) -> List[Tuple[str, int, int]]:
554
  """Find aspect keyword matches; return (label, start, end) sorted by position."""
555
  found: List[Tuple[str, int, int]] = []
 
12
 
13
  import os
14
  import re
 
15
  import threading
16
+ import time
17
  from pathlib import Path
18
+ from typing import List, Optional, Tuple
19
+
20
  import numpy as np
21
 
22
+ from api.schemas.schemas import AspectSentiment, PredictionResponse
23
+ from api.services.lang_service import lang_service
24
 
25
  # ── Optional heavy imports (ONNX custom models) ───────────────────────────────
26
  try:
27
  from optimum.onnxruntime import (
 
28
  ORTModelForSequenceClassification,
29
+ ORTModelForTokenClassification,
30
  )
31
  from transformers import AutoTokenizer
32
 
 
144
  reverse=True,
145
  )
146
 
147
+ # ── Fallback aspect nouns ─────────────────────────────────────────────────────
148
+ # Used when the fixed lexicon misses a comment (e.g. food/service/experience).
149
+ # These are the nouns we anchor near sentiment words when no lexicon aspect
150
+ # matches. English + transliterated + Devanagari.
151
+ GENERAL_ASPECT_NOUNS: set[str] = {
152
+ # Experience / service
153
+ "food",
154
+ "taste",
155
+ "flavor",
156
+ "flavour",
157
+ "service",
158
+ "staff",
159
+ "experience",
160
+ "product",
161
+ "quality",
162
+ "delivery",
163
+ "packaging",
164
+ "price",
165
+ "cost",
166
+ "value",
167
+ "design",
168
+ "look",
169
+ "feel",
170
+ "build",
171
+ "material",
172
+ "comfort",
173
+ "durability",
174
+ "cleanliness",
175
+ "hygiene",
176
+ "room",
177
+ "location",
178
+ "ambience",
179
+ "menu",
180
+ "portion",
181
+ "hotel",
182
+ "restaurant",
183
+ "support",
184
+ "response",
185
+ "warranty",
186
+ "atmosphere",
187
+ "purchase",
188
+ "order",
189
+ "buy",
190
+ # Devices / electronics
191
+ "phone",
192
+ "smartphone",
193
+ "laptop",
194
+ "tablet",
195
+ "headphones",
196
+ "earphones",
197
+ "earbuds",
198
+ "watch",
199
+ "device",
200
+ "camera",
201
+ "screen",
202
+ "display",
203
+ "battery",
204
+ "speaker",
205
+ "speakers",
206
+ "keyboard",
207
+ "mouse",
208
+ "monitor",
209
+ "processor",
210
+ "performance",
211
+ "speed",
212
+ "sound",
213
+ "audio",
214
+ "picture",
215
+ "photo",
216
+ "video",
217
+ "signal",
218
+ "network",
219
+ "call",
220
+ "app",
221
+ "software",
222
+ "interface",
223
+ "ui",
224
+ "features",
225
+ "battery life",
226
+ "charging",
227
+ "processor",
228
+ "ram",
229
+ "memory",
230
+ # Hindi transliterated
231
+ "khana",
232
+ "swad",
233
+ "delivery",
234
+ "experience",
235
+ "speed",
236
+ "sound",
237
+ "signal",
238
+ # Devanagari
239
+ "खाना",
240
+ "सेवा",
241
+ "गुणवत्ता",
242
+ "कीमत",
243
+ "डिज़ाइन",
244
+ "उत्पाद",
245
+ "अनुभव",
246
+ "बैटरी",
247
+ "कैमरा",
248
+ "प्रदर्शन",
249
+ "आवाज़",
250
+ "स्पीड",
251
+ "डिलीवरी",
252
+ "स्वाद",
253
+ }
254
+
255
  # ── Sentiment lexicon ─────────────────────────────────────────────────────────
256
  POSITIVE_WORDS = {
257
  "excellent",
 
291
  "recommended",
292
  "worth",
293
  "affordable",
294
+ "best",
295
+ "better",
296
+ "awesome",
297
+ "favorite",
298
+ "favourite",
299
  "value",
300
  "effective",
301
  "efficient",
 
329
  "shandar",
330
  "zabardast",
331
  "mast",
332
+ # Hindi positive (Devanagari)
333
+ "अच्छा",
334
+ "बढ़िया",
335
+ "शानदार",
336
+ "ज़बरदस्त",
337
+ "मस्त",
338
+ "पसंद",
339
+ "उत्तम",
340
+ "उम्दा",
341
+ "सुंदर",
342
+ "बेहतरीन",
343
+ "अद्भुत",
344
  }
345
 
346
  NEGATIVE_WORDS = {
 
397
  "broken",
398
  "defective",
399
  "faulty",
400
+ "worst",
401
+ "worse",
402
+ "useless",
403
+ "pathetic",
404
  "average",
405
  "ordinary",
406
  "basic",
 
411
  "bura",
412
  "ganda",
413
  "faltu",
414
+ # Hindi negative (Devanagari)
415
+ "खराब",
416
+ "बेकार",
417
+ "बुरा",
418
+ "घटिया",
419
+ "फालतू",
420
+ "निराश",
421
+ "सस्ता",
422
  }
423
 
424
  NEGATION_WORDS = {
 
460
  }
461
 
462
 
463
+ # Unicode-aware token pattern. `\w` alone misses Devanagari vowel signs
464
+ # (combining marks), which would split "खाना" into stray characters, so the
465
+ # Devanagari block is included explicitly.
466
+ WORD_RE = re.compile(r"[\w\u0900-\u097f'-]+")
467
+
468
+ # Split a review into sentiment clauses on punctuation and conjunctions, so a
469
+ # sentence like "food was great but service was terrible" is scored per-clause.
470
+ # Includes English + transliterated + Devanagari conjunctions. The Devanagari
471
+ # ones need whitespace around them, otherwise "या" matches inside "��ढ़िया".
472
+ _CLAUSE_SPLIT_RE = re.compile(
473
+ r"[.!?;,]|\b(?:but|and|yet|however|although|though|while|whereas|because|"
474
+ r"since|so|or|nor|lekin|par|magar|aur|kintu|va|ya)\b|"
475
+ r"(?<=\s)(?:लेकिन|और|पर|मगर|किंतु)(?=\s)"
476
+ )
477
+
478
+
479
+ def _split_clauses(text: str) -> List[str]:
480
+ """Split a review into clauses (punctuation + conjunction boundaries)."""
481
+ parts = [p for p in _CLAUSE_SPLIT_RE.split(text) if p and p.strip()]
482
+ return parts or [text]
483
+
484
+
485
  def _score_sentence(sentence: str) -> Tuple[float, float]:
486
  """
487
  Return (positive_score, negative_score) for a sentence.
488
  Handles negation (3-word window) and intensifiers.
489
  """
490
+ words = WORD_RE.findall(sentence.lower())
491
  pos, neg = 0.0, 0.0
492
  i = 0
493
  while i < len(words):
 
559
  self.aspect_model = ORTModelForTokenClassification.from_pretrained(
560
  hf_repo_id, subfolder="aspect_extraction_int8"
561
  )
562
+ self.sentiment_model = ORTModelForSequenceClassification.from_pretrained(
563
+ hf_repo_id, subfolder="sentiment_int8"
 
 
564
  )
565
  print("Custom ONNX models loaded.")
566
  except Exception as e:
 
578
  try:
579
  print(f"Loading custom ONNX models from {model_path_base}")
580
  self.tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
581
+ self.aspect_model = ORTModelForTokenClassification.from_pretrained(str(aspect_path))
582
+ self.sentiment_model = ORTModelForSequenceClassification.from_pretrained(str(sentiment_path))
 
 
 
 
 
 
583
  print("Custom ONNX models loaded.")
584
  except Exception as e:
585
  print(f"Custom model load skipped: {e}")
 
589
 
590
  self.is_loaded = True
591
 
592
+ def predict(self, text: str, requested_lang: Optional[str] = None) -> PredictionResponse:
593
  start = time.time()
594
  detected_lang = lang_service.detect_language(text)
595
  actual_lang = requested_lang or detected_lang
 
613
  # ── Custom ONNX path ──────────────────────────────────────────────────────
614
 
615
  def _predict_onnx(self, text: str) -> List[AspectSentiment]:
616
+ assert self.aspect_model is not None
617
+ assert self.sentiment_model is not None
618
+ assert self.tokenizer is not None
619
+ inputs = self.tokenizer(text, return_tensors="pt", truncation=True, max_length=128)
620
  logits = self.aspect_model(**inputs).logits[0].detach().numpy()
621
  preds = np.argmax(logits, axis=1)
622
  tokens = self.tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
623
 
624
+ raw: List[Tuple[str, int, int]] = []
625
+ current: List[str] = []
626
+ start_idx = -1
627
  skip = {
628
  self.tokenizer.cls_token,
629
  self.tokenizer.sep_token,
 
674
 
675
  def _predict_rule_based(self, text: str) -> List[AspectSentiment]:
676
  text_lower = text.lower()
677
+ clauses = _split_clauses(text)
678
  found_aspects = self._extract_aspects(text_lower)
679
 
680
+ # Tier 2: no lexicon aspect matched — anchor nouns near sentiment words.
681
+ if not found_aspects:
682
+ found_aspects = self._extract_targets_from_sentiment(text_lower)
683
+
684
+ # Tier 3: still nothing — fall back to review-level sentiment on a
685
+ # generic aspect so every input produces a useful result.
686
+ if not found_aspects:
687
+ found_aspects = [("Overall", 0, len(text))]
688
+
689
  results = []
690
  for aspect_label, start_char, end_char in found_aspects:
691
+ # Score only the clause(s) mentioning this aspect so that mixed
692
+ # sentences ("food was great but service was terrible") don't
693
+ # cancel each other into neutral.
694
  aspect_lower = aspect_label.lower()
695
+ context_clauses = [c for c in clauses if aspect_lower in c.lower()] or [text]
696
+ context = " ".join(context_clauses)
 
 
697
 
698
  pos, neg = _score_sentence(context)
699
 
 
714
  )
715
  return results
716
 
717
+ def _extract_targets_from_sentiment(self, text_lower: str) -> List[Tuple[str, int, int]]:
718
+ """Fallback aspect extraction: nouns found near sentiment words.
719
+
720
+ Handles cases like "The food was great" (noun before the sentiment
721
+ word) and "great camera" (noun after it), plus Devanagari text.
722
+ """
723
+ tokens = [(m.start(), m.end(), m.group()) for m in WORD_RE.finditer(text_lower)]
724
+ sent_words = POSITIVE_WORDS | NEGATIVE_WORDS
725
+
726
+ targets: List[Tuple[str, int, int]] = []
727
+ seen_ranges: List[Tuple[int, int]] = []
728
+
729
+ for i, (_, _, token) in enumerate(tokens):
730
+ if token not in sent_words:
731
+ continue
732
+ window = tokens[max(0, i - 4) : i] + tokens[i + 1 : i + 5]
733
+ for s, e, noun in window:
734
+ if noun in GENERAL_ASPECT_NOUNS:
735
+ if any(s >= r0 and e <= r1 for r0, r1 in seen_ranges):
736
+ continue
737
+ targets.append((noun.title(), s, e))
738
+ seen_ranges.append((s, e))
739
+ break
740
+
741
+ targets.sort(key=lambda x: x[1])
742
+ return targets
743
+
744
  def _extract_aspects(self, text_lower: str) -> List[Tuple[str, int, int]]:
745
  """Find aspect keyword matches; return (label, start, end) sorted by position."""
746
  found: List[Tuple[str, int, int]] = []
{app → api}/services/lang_service.py RENAMED
File without changes
{app → api}/tasks/__init__.py RENAMED
File without changes
{app → api}/tasks/batch_tasks.py RENAMED
@@ -1,7 +1,7 @@
1
- from app.tasks import celery_app
2
- from app.services.absa_pipeline import pipeline
3
- from app.middleware.dependencies import SessionLocal
4
- from app.schemas.db_models import BatchJob, AspectResult, Review
5
  import pandas as pd
6
  import os
7
  import csv
 
1
+ from api.tasks import celery_app
2
+ from api.services.absa_pipeline import pipeline
3
+ from api.middleware.dependencies import SessionLocal
4
+ from api.schemas.db_models import BatchJob, AspectResult, Review
5
  import pandas as pd
6
  import os
7
  import csv
app/core/templates.py DELETED
@@ -1,36 +0,0 @@
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 import Request
18
- from fastapi.templating import Jinja2Templates
19
-
20
- # Resolve relative to this file:
21
- # api/app/core/templates.py → api/app/templates/
22
- _TEMPLATE_DIR: Path = Path(__file__).parent.parent / "templates"
23
-
24
- templates = Jinja2Templates(directory=str(_TEMPLATE_DIR))
25
-
26
-
27
- # ── Global template context processor ─────────────────────────────────────────
28
- # Ensures every template rendered via this instance always has access to
29
- # csrf_token — even partial/fragment templates that don't go through _base_ctx.
30
-
31
- def _csrf_processor(request: Request) -> dict: # type: ignore[no-redef]
32
- from app.middleware.csrf import generate_csrf_token
33
- return {"csrf_token": generate_csrf_token()}
34
-
35
-
36
- templates.context_processors.append(_csrf_processor) # type: ignore[arg-type]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/middleware/csrf.py DELETED
@@ -1,59 +0,0 @@
1
- import os
2
- import re
3
- from typing import Optional
4
- from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired
5
- from starlette.middleware.base import BaseHTTPMiddleware
6
- from starlette.requests import Request
7
- from starlette.responses import Response
8
-
9
- _CSRF_SECRET = os.getenv("CSRF_SECRET", "unsafe-default-change-in-production")
10
- _CSRF_SALT = "csrf-token"
11
- _SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
12
- _EXEMPT_PATHS = {"/metrics", "/health", "/info", "/docs", "/openapi.json"}
13
-
14
- _serializer = URLSafeTimedSerializer(_CSRF_SECRET, salt=_CSRF_SALT)
15
-
16
-
17
- def generate_csrf_token() -> str:
18
- return _serializer.dumps("csrf")
19
-
20
-
21
- def validate_csrf_token(token: str, max_age: int = 3600) -> bool:
22
- try:
23
- _serializer.loads(token, max_age=max_age)
24
- return True
25
- except (BadSignature, SignatureExpired):
26
- return False
27
-
28
-
29
- class CSRFMiddleware(BaseHTTPMiddleware):
30
- async def dispatch(self, request: Request, call_next):
31
- path = request.url.path
32
- needs_csrf = request.method in {"POST"} and path.endswith("/fragment")
33
- is_html_page = request.method in _SAFE_METHODS and not path.startswith("/api/") and not path.startswith("/static/") and path not in _EXEMPT_PATHS
34
-
35
- if needs_csrf:
36
- csrf_cookie = request.cookies.get("csrf_token", "")
37
- csrf_header = request.headers.get("X-CSRF-Token", "")
38
- token = csrf_header or csrf_cookie
39
-
40
- if token and not validate_csrf_token(str(token)):
41
- from fastapi.responses import HTMLResponse
42
- return HTMLResponse(
43
- content="<h1>403: CSRF validation failed</h1><p>Invalid or expired token. Please refresh the page.</p>",
44
- status_code=403,
45
- )
46
-
47
- response: Response = await call_next(request)
48
-
49
- if is_html_page:
50
- response.set_cookie(
51
- key="csrf_token",
52
- value=generate_csrf_token(),
53
- max_age=3600,
54
- secure=False,
55
- httponly=True,
56
- samesite="lax",
57
- )
58
-
59
- return response
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/routes/pages.py DELETED
@@ -1,327 +0,0 @@
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, Depends, Form
31
- from fastapi.responses import HTMLResponse
32
- from sqlalchemy.orm import Session
33
-
34
- from app.core.templates import templates
35
- from app.middleware.csrf import generate_csrf_token
36
- from app.middleware.dependencies import get_db
37
-
38
- # include_in_schema=False keeps these HTML routes out of the OpenAPI / Swagger UI.
39
- router = APIRouter(include_in_schema=False)
40
-
41
- # ── Navigation structure ───────────────────────────────────────────────────────
42
- # Mirrors the NAV constant in the React Sidebar.jsx so sidebar rendering is
43
- # driven from a single Python list rather than hard-coded in every template.
44
- _NAV_ITEMS: list[dict[str, str]] = [
45
- {"path": "/predict", "icon": "psychology", "label": "Predictor"},
46
- {"path": "/batch", "icon": "cloud_upload", "label": "Batch Analytics"},
47
- {"path": "/monitor", "icon": "monitoring", "label": "System Health"},
48
- ]
49
-
50
-
51
- def _base_ctx(request: Request, page_title: str, **extra: object) -> dict:
52
- """
53
- Build the Jinja2 template context that base.html expects.
54
-
55
- Every page renderer calls this so the sidebar and header always receive
56
- the nav items and the current path (for active-link highlighting).
57
- Also includes CSRF token for HTMX form submissions.
58
- """
59
- from app.middleware.csrf import generate_csrf_token
60
- return {
61
- "request": request, # required by Jinja2Templates
62
- "page_title": page_title,
63
- "nav_items": _NAV_ITEMS,
64
- "current_path": request.url.path,
65
- "csrf_token": generate_csrf_token(),
66
- **extra,
67
- }
68
-
69
-
70
- # ── Routes ─────────────────────────────────────────────────────────────────────
71
-
72
- @router.get("/", response_class=HTMLResponse)
73
- async def index(request: Request) -> HTMLResponse:
74
- """Root → serve the Predict page (same behaviour as React's Navigate redirect)."""
75
- return templates.TemplateResponse(
76
- "pages/predict.html",
77
- _base_ctx(request, "Live Predictor"),
78
- )
79
-
80
-
81
- @router.get("/predict", response_class=HTMLResponse)
82
- async def predict_page(request: Request) -> HTMLResponse:
83
- """
84
- Jinja2 Live Predictor page.
85
- Phase 2: renders the layout shell with a placeholder content block.
86
- Phase 3: the content block will contain the HTMX predict form + result panel.
87
- """
88
- return templates.TemplateResponse(
89
- "pages/predict.html",
90
- _base_ctx(request, "Live Predictor"),
91
- )
92
-
93
-
94
- @router.get("/batch", response_class=HTMLResponse)
95
- async def batch_page(request: Request) -> HTMLResponse:
96
- """
97
- Jinja2 Batch Analytics page.
98
- Phase 2: placeholder.
99
- Phase 4: file upload form + progress polling.
100
- """
101
- return templates.TemplateResponse(
102
- "pages/batch.html",
103
- _base_ctx(request, "Batch Analytics"),
104
- )
105
-
106
-
107
- from app.routes.results import health_check
108
-
109
- @router.get("/monitor", response_class=HTMLResponse)
110
- async def monitor_page(request: Request) -> HTMLResponse:
111
- """
112
- Jinja2 System Monitor page.
113
- Phase 5: live health status + performance metrics.
114
- """
115
- try:
116
- health = await health_check()
117
- ctx = _base_ctx(request, "System Monitor", health=health, error=None)
118
- except Exception:
119
- ctx = _base_ctx(request, "System Monitor", health=None, error="Service temporarily unavailable")
120
-
121
- return templates.TemplateResponse("pages/monitor.html", ctx)
122
- # ── SSE Endpoint for batch progress ──────────────────────────────────────────
123
-
124
- import asyncio
125
- import json
126
- from sse_starlette.sse import EventSourceResponse
127
-
128
- @router.get("/api/batch/progress/{job_id}")
129
- async def batch_progress_sse(job_id: str, db: Session = Depends(get_db)):
130
- """
131
- SSE endpoint for live batch progress updates.
132
- Clients connect via EventSource and receive progress events every 2 seconds.
133
- """
134
- async def event_generator():
135
- try:
136
- import re
137
- if not re.match(r'^[a-fA-F0-9\-]{36}$', job_id):
138
- yield {"event": "error", "data": json.dumps({"detail": "Invalid job ID"})}
139
- return
140
-
141
- while True:
142
- job = await get_batch_status(job_id, db)
143
- data = {
144
- "job_id": job.job_id,
145
- "status": job.status,
146
- "total_reviews": job.total_reviews,
147
- "processed": job.processed,
148
- "result_url": job.result_url,
149
- }
150
- yield {"event": "progress", "data": json.dumps(data)}
151
-
152
- if job.status in ("completed", "failed"):
153
- yield {"event": job.status, "data": json.dumps(data)}
154
- break
155
-
156
- await asyncio.sleep(2)
157
- except Exception:
158
- yield {"event": "error", "data": json.dumps({"detail": "Failed to fetch job progress"})}
159
-
160
- return EventSourceResponse(event_generator())
161
-
162
- # ── Phase 3 HTMX Endpoints ───────────────────────────────────────────────────
163
-
164
- from app.schemas.schemas import ReviewInput
165
- from app.routes.predict import predict as predict_json
166
-
167
- @router.post("/predict/fragment", response_class=HTMLResponse)
168
- async def predict_fragment(
169
- request: Request,
170
- text: str = Form(...),
171
- language: str = Form("auto"),
172
- db: Session = Depends(get_db)
173
- ) -> HTMLResponse:
174
- """
175
- Phase 3: HTMX partial for the Predict page.
176
- Calls the EXACT SAME prediction logic as the JSON API.
177
- """
178
- try:
179
- if language == "auto":
180
- language = None
181
-
182
- prediction = await predict_json(ReviewInput(text=text, language=language), db)
183
- return templates.TemplateResponse(
184
- "partials/predict_result.html",
185
- {"request": request, "result": prediction, "error": None}
186
- )
187
- except Exception:
188
- return templates.TemplateResponse(
189
- "partials/predict_result.html",
190
- {"request": request, "result": None, "error": "Analysis failed. Please try again."}
191
- )
192
-
193
- # ── Phase 4 HTMX Endpoints ───────────────────────────────────────────────────
194
-
195
- from fastapi import UploadFile, File
196
- from app.routes.predict import predict_batch, get_batch_status
197
-
198
- @router.post("/batch/fragment", response_class=HTMLResponse)
199
- async def batch_fragment(
200
- request: Request,
201
- file: UploadFile = File(...),
202
- db: Session = Depends(get_db)
203
- ) -> HTMLResponse:
204
- """
205
- Phase 4: HTMX partial for starting a batch job.
206
- """
207
- try:
208
- response = await predict_batch(file, db)
209
- return templates.TemplateResponse(
210
- "partials/batch_progress.html",
211
- {"request": request, "job": response, "error": None}
212
- )
213
- except Exception:
214
- return templates.TemplateResponse(
215
- "partials/batch_progress.html",
216
- {"request": request, "job": None, "error": "Batch processing failed. Please try again."}
217
- )
218
-
219
- @router.get("/batch/progress/{job_id}", response_class=HTMLResponse)
220
- async def batch_progress_fragment(
221
- request: Request,
222
- job_id: str,
223
- db: Session = Depends(get_db)
224
- ) -> HTMLResponse:
225
- """
226
- Phase 4: HTMX partial for polling batch job status.
227
- """
228
- try:
229
- job = await get_batch_status(job_id, db)
230
- return templates.TemplateResponse(
231
- "partials/batch_progress.html",
232
- {"request": request, "job": job, "error": None}
233
- )
234
- except Exception:
235
- return templates.TemplateResponse(
236
- "partials/batch_progress.html",
237
- {"request": request, "job": None, "error": "Failed to retrieve job status."}
238
- )
239
-
240
- # ── Phase 5 HTMX Endpoints ───────────────────────────────────────────────────
241
-
242
- @router.get("/monitor/health-partial", response_class=HTMLResponse)
243
- async def monitor_health_fragment(request: Request) -> HTMLResponse:
244
- """
245
- Phase 5: HTMX partial for polling the system health status.
246
- Uses the exact same health logic as the JSON API.
247
- """
248
- try:
249
- health = await health_check()
250
- return templates.TemplateResponse(
251
- "partials/monitor_health.html",
252
- {"request": request, "health": health, "error": None}
253
- )
254
- except Exception:
255
- return templates.TemplateResponse(
256
- "partials/monitor_health.html",
257
- {"request": request, "health": None, "error": "Health check failed. Service may be unavailable."}
258
- )
259
-
260
- import pandas as pd
261
- from pathlib import Path
262
- import json
263
-
264
- @router.get("/batch/charts/{job_id}", response_class=HTMLResponse)
265
- async def batch_charts_fragment(request: Request, job_id: str) -> HTMLResponse:
266
- """
267
- Phase 5.6: HTMX partial for rendering charts.
268
- Parses the generated CSV and passes JSON directly to the template for Chart.js.
269
- """
270
- try:
271
- file_path = Path(f"data/results/{job_id}.csv")
272
- if not file_path.exists():
273
- return templates.TemplateResponse("partials/batch_charts.html", {"request": request, "error": "CSV not found"})
274
-
275
- df = pd.read_csv(file_path)
276
-
277
- lang_pie = []
278
- if "language" in df.columns:
279
- counts = df["language"].value_counts().to_dict()
280
- lang_pie = [{"name": str(k), "value": int(v)} for k, v in counts.items()]
281
-
282
- aspect_heat = []
283
- if "aspect" in df.columns and "sentiment" in df.columns:
284
- # Group by aspect and sentiment
285
- grouped = df.groupby(["aspect", "sentiment"]).size().unstack(fill_value=0)
286
- for aspect, row in grouped.iterrows():
287
- if pd.isna(aspect) or not aspect:
288
- continue
289
- aspect_heat.append({
290
- "aspect": str(aspect),
291
- "positive": int(row.get("positive", 0)),
292
- "negative": int(row.get("negative", 0)),
293
- "neutral": int(row.get("neutral", 0)),
294
- "conflict": int(row.get("conflict", 0))
295
- })
296
-
297
- sent_line = []
298
- if "sentiment" in df.columns:
299
- df_sent = df[df["sentiment"].notna()]
300
- n = len(df_sent)
301
- # Create 7 chunks for the line chart
302
- chunk_size = max(1, n // 7) if n > 0 else 1
303
- for i in range(7):
304
- chunk = df_sent.iloc[i*chunk_size : (i+1)*chunk_size]
305
- if chunk.empty:
306
- break
307
- counts = chunk["sentiment"].value_counts().to_dict()
308
- sent_line.append({
309
- "name": f"Batch {i+1}",
310
- "positive": int(counts.get("positive", 0)),
311
- "negative": int(counts.get("negative", 0)),
312
- "neutral": int(counts.get("neutral", 0)),
313
- "conflict": int(counts.get("conflict", 0))
314
- })
315
-
316
- return templates.TemplateResponse(
317
- "partials/batch_charts.html",
318
- {
319
- "request": request,
320
- "language_pie": json.dumps(lang_pie),
321
- "aspect_heatmap": json.dumps(aspect_heat),
322
- "sentiment_chart": json.dumps(sent_line),
323
- "error": None
324
- }
325
- )
326
- except Exception:
327
- return templates.TemplateResponse("partials/batch_charts.html", {"request": request, "error": "An unexpected error occurred while generating charts."})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/static/css/app.css DELETED
@@ -1,497 +0,0 @@
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; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/base.html DELETED
@@ -1,449 +0,0 @@
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
- <meta name="csrf-token" content="{{ csrf_token }}">
9
-
10
- {# ── Fonts ────────────────────────────────────────────────────────────────── #}
11
- {# Inter replaces Geist (same design language, available on Google Fonts CDN). #}
12
- {# JetBrains Mono is used verbatim from the original tailwind.config.js. #}
13
- <link rel="preconnect" href="https://fonts.googleapis.com" />
14
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
15
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap"
16
- rel="stylesheet" />
17
-
18
- {# ── Material Symbols Outlined icon font ──────────────────────────────────── #}
19
- {# Variable-font version so FILL and wght axes are available (matching React). #}
20
- <link rel="stylesheet"
21
- href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
22
-
23
- {#
24
- ── Tailwind CSS (Play CDN) ───────────────────────────────────────────────
25
- The CDN version scans the DOM at runtime and generates utilities on demand.
26
- This eliminates the npm build step. Custom design tokens from the original
27
- tailwind.config.js are provided in the tailwind.config object below.
28
-
29
- NOTE: The config <script> MUST come AFTER the CDN <script> tag.
30
- #}
31
- <script src="https://cdn.tailwindcss.com"></script>
32
- <script>
33
- tailwind.config = {
34
- darkMode: 'class',
35
- theme: {
36
- extend: {
37
- colors: {
38
- // Surface palette — from tailwind.config.js
39
- "surface": "#0b1326",
40
- "surface-dim": "#0b1326",
41
- "surface-bright": "#31394d",
42
- "surface-container-lowest": "#060e20",
43
- "surface-container-low": "#131b2e",
44
- "surface-container": "#171f33",
45
- "surface-container-high": "#222a3d",
46
- "surface-container-highest": "#2d3449",
47
- "surface-variant": "#2d3449",
48
- "background": "#0b1326",
49
- // On-surface
50
- "on-surface": "#dae2fd",
51
- "on-surface-variant": "#c7c4d7",
52
- "on-background": "#dae2fd",
53
- // Primary
54
- "primary": "#c0c1ff",
55
- "primary-container": "#8083ff",
56
- "on-primary": "#1000a9",
57
- // Secondary
58
- "secondary": "#c4c7c9",
59
- // Tertiary (positive sentiment — green)
60
- "tertiary": "#4edea3",
61
- "tertiary-container": "#00885d",
62
- "on-tertiary": "#003824",
63
- // Error (negative sentiment — red)
64
- "error": "#ffb4ab",
65
- "error-container": "#93000a",
66
- "on-error": "#690005",
67
- // Outline
68
- "outline": "#908fa0",
69
- "outline-variant": "#464554",
70
- // Semantic aliases
71
- "positive": "#4edea3",
72
- "negative": "#ffb4ab",
73
- "warning": "#f5c542",
74
- },
75
- fontFamily: {
76
- sans: ["Inter", "ui-sans-serif", "system-ui", "sans-serif"],
77
- mono: ["JetBrains Mono", "ui-monospace", "monospace"],
78
- },
79
- fontSize: {
80
- "display": ["36px", { lineHeight: "44px", letterSpacing: "-0.025em", fontWeight: "600" }],
81
- "headline-lg": ["32px", { lineHeight: "40px", letterSpacing: "-0.02em", fontWeight: "600" }],
82
- "headline-md": ["24px", { lineHeight: "32px", letterSpacing: "-0.01em", fontWeight: "600" }],
83
- "headline-sm": ["20px", { lineHeight: "28px", fontWeight: "500" }],
84
- "title-lg": ["16px", { lineHeight: "24px", fontWeight: "600" }],
85
- "title-md": ["14px", { lineHeight: "20px", fontWeight: "600" }],
86
- "body-lg": ["16px", { lineHeight: "24px", fontWeight: "400" }],
87
- "body-md": ["14px", { lineHeight: "20px", fontWeight: "400" }],
88
- "body-sm": ["12px", { lineHeight: "16px", fontWeight: "400" }],
89
- "label-lg": ["14px", { lineHeight: "20px", letterSpacing: "0.02em", fontWeight: "500" }],
90
- "label-md": ["12px", { lineHeight: "16px", letterSpacing: "0.05em", fontWeight: "500" }],
91
- "label-sm": ["11px", { lineHeight: "16px", letterSpacing: "0.06em", fontWeight: "500" }],
92
- },
93
- spacing: {
94
- "xs": "4px",
95
- "sm": "8px",
96
- "md": "12px",
97
- "lg": "16px",
98
- "xl": "24px",
99
- "2xl": "32px",
100
- "3xl": "48px",
101
- },
102
- borderRadius: {
103
- "sm": "4px",
104
- "DEFAULT": "6px",
105
- "md": "8px",
106
- "lg": "12px",
107
- "xl": "16px",
108
- "2xl": "20px",
109
- "full": "9999px",
110
- },
111
- boxShadow: {
112
- "card": "0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)",
113
- "elevated": "0 4px 16px rgba(0,0,0,0.5)",
114
- "glow-primary": "0 0 20px rgba(192,193,255,0.15)",
115
- "glow-positive": "0 0 12px rgba(78,222,163,0.20)",
116
- "glow-negative": "0 0 12px rgba(255,180,171,0.20)",
117
- },
118
- backdropBlur: {
119
- "glass": "12px",
120
- },
121
- animation: {
122
- "pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
123
- "fade-in": "fadeIn 0.2s ease-out",
124
- "slide-in": "slideIn 0.25s ease-out",
125
- },
126
- keyframes: {
127
- fadeIn: { from: { opacity: "0" }, to: { opacity: "1" } },
128
- slideIn: { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
129
- },
130
- // Safelist ensures Alpine.js-toggled classes are always generated
131
- safelist: ["translate-x-0", "-translate-x-full"],
132
- },
133
- },
134
- };
135
- </script>
136
-
137
- {# ── Custom design system CSS ──────────────────────────────────────────────── #}
138
- {# Component classes that use @apply in the React version are written as plain #}
139
- {# CSS in app.css (since the Play CDN does not process @apply in external CSS). #}
140
- <link rel="stylesheet" href="{{ request.url_for('static', path='css/app.css') }}" />
141
-
142
- {# ── Per-page extra head content ──────────────────────────────────────────── #}
143
- {% block head %}{% endblock %}
144
- </head>
145
-
146
- <body class="bg-[#0b1326] text-[#dae2fd] antialiased font-sans"
147
- x-data="appState()"
148
- @notify.window="addToast($event.detail.msg, $event.detail.type)">
149
-
150
- {# ── Toast notification container ────────────────────────────────────────── #}
151
- {# Managed by Alpine.js appState(). HTMX error events dispatch to this. #}
152
- <div class="fixed top-4 right-4 z-[100] space-y-2 pointer-events-none w-80"
153
- role="status"
154
- aria-live="polite"
155
- aria-atomic="false">
156
- <template x-for="toast in toasts" :key="toast.id">
157
- <div x-show="toast.visible"
158
- x-transition:enter="transition ease-out duration-200"
159
- x-transition:enter-start="opacity-0 translate-y-1"
160
- x-transition:enter-end="opacity-100 translate-y-0"
161
- x-transition:leave="transition ease-in duration-150"
162
- x-transition:leave-start="opacity-100"
163
- x-transition:leave-end="opacity-0"
164
- class="toast pointer-events-auto"
165
- :class="{
166
- 'toast--success': toast.type === 'success',
167
- 'toast--error': toast.type === 'error',
168
- 'toast--info': toast.type === 'info',
169
- }"
170
- x-text="toast.msg">
171
- </div>
172
- </template>
173
- </div>
174
-
175
- {# ── Page wrapper ─────────────────────────────────────────────────────────── #}
176
- <div class="min-h-screen flex">
177
-
178
- {# ── Mobile sidebar overlay ──────────────────────────────────────────────── #}
179
- <div x-show="sidebarOpen"
180
- x-transition:enter="transition ease-out duration-200"
181
- x-transition:enter-start="opacity-0"
182
- x-transition:enter-end="opacity-100"
183
- x-transition:leave="transition ease-in duration-150"
184
- x-transition:leave-start="opacity-100"
185
- x-transition:leave-end="opacity-0"
186
- @click="sidebarOpen = false"
187
- class="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
188
- aria-hidden="true"
189
- style="display: none;"></div>
190
-
191
- {# ── Sidebar ───────────────────────���─────────────────────────────────────── #}
192
- {#
193
- The sidebar is always visible on desktop (md:translate-x-0) and slides in
194
- on mobile when sidebarOpen is true. Alpine.js toggles the translation via
195
- :class binding. The safelist in tailwind.config ensures translate-x-0 and
196
- -translate-x-full are always generated by the CDN.
197
- #}
198
- <nav aria-label="Main navigation"
199
- :class="sidebarOpen ? 'translate-x-0' : '-translate-x-full'"
200
- class="fixed left-0 top-0 h-screen w-64 z-50 flex flex-col
201
- bg-[#171f33] border-r border-white/[0.07]
202
- transition-transform duration-[250ms] ease-out
203
- md:translate-x-0">
204
-
205
- {# Logo #}
206
- <div class="flex items-center gap-3 px-xl py-xl">
207
- <span class="material-symbols-outlined text-[#c0c1ff]"
208
- style="font-size:28px; font-variation-settings: 'FILL' 1, 'wght' 400;"
209
- aria-hidden="true">psychology</span>
210
- <div>
211
- <p class="text-[16px] font-semibold text-[#c0c1ff] leading-tight">SentimentAI</p>
212
- <p class="font-mono text-[11px] text-[#c7c4d7] tracking-wider">Analysis Engine v2.4</p>
213
- </div>
214
- </div>
215
-
216
- {# CTA — New Analysis #}
217
- <div class="px-xl mb-xl">
218
- <a href="/predict"
219
- @click="sidebarOpen = false"
220
- class="btn-primary w-full text-[12px]">
221
- <span class="material-symbols-outlined"
222
- style="font-size:18px; font-variation-settings: 'FILL' 0, 'wght' 300;"
223
- aria-hidden="true">add</span>
224
- New Analysis
225
- </a>
226
- </div>
227
-
228
- {# Navigation links #}
229
- <ul class="flex-1 px-sm space-y-0.5 overflow-y-auto" role="list">
230
- {% for item in nav_items %}
231
- {% set active = current_path == item.path or (current_path == '/' and item.path == '/predict') %}
232
- <li role="listitem">
233
- <a href="{{ item.path }}"
234
- @click="sidebarOpen = false"
235
- class="{{ 'nav-item-active' if active else 'nav-item' }}"
236
- {% if active %}aria-current="page"{% endif %}>
237
- <span class="material-symbols-outlined"
238
- style="font-size:20px; font-variation-settings: 'FILL' {{ '1' if active else '0' }}, 'wght' 300;"
239
- aria-hidden="true">{{ item.icon }}</span>
240
- <span>{{ item.label }}</span>
241
- </a>
242
- </li>
243
- {% endfor %}
244
- </ul>
245
-
246
- {# Bottom section: health pill + secondary nav #}
247
- <div class="px-sm pt-sm pb-xl border-t border-white/[0.06] space-y-0.5 mt-auto">
248
-
249
- {# API health indicator — static in Phase 2; HTMX polling added in Phase 5 #}
250
- <div id="sidebar-health"
251
- class="flex items-center gap-2 px-3 py-2 mb-1">
252
- <span class="w-2 h-2 rounded-full bg-[#4edea3] flex-shrink-0 animate-pulse-slow"
253
- aria-hidden="true"></span>
254
- <span class="font-mono text-[12px] text-[#c7c4d7]">API Online</span>
255
- </div>
256
-
257
- <a href="/monitor"
258
- @click="sidebarOpen = false"
259
- class="{{ 'nav-item-active' if current_path == '/monitor' else 'nav-item' }}">
260
- <span class="material-symbols-outlined"
261
- style="font-size:20px; font-variation-settings: 'FILL' 0, 'wght' 300;"
262
- aria-hidden="true">settings</span>
263
- <span>Settings</span>
264
- </a>
265
-
266
- <a href="/docs"
267
- target="_blank"
268
- rel="noopener noreferrer"
269
- class="nav-item">
270
- <span class="material-symbols-outlined"
271
- style="font-size:20px; font-variation-settings: 'FILL' 0, 'wght' 300;"
272
- aria-hidden="true">menu_book</span>
273
- <span>API Docs</span>
274
- </a>
275
- </div>
276
- </nav>
277
-
278
- {# ── Main area (right of sidebar) ────────────────────────────────────────── #}
279
- <div class="flex-1 md:ml-64 flex flex-col min-h-screen">
280
-
281
- {# Top header bar #}
282
- <header class="fixed top-0 right-0 left-0 md:left-64 h-16 z-30
283
- bg-[#0b1326]/80 backdrop-blur-[12px]
284
- border-b border-white/[0.06]
285
- flex items-center justify-between px-xl gap-4">
286
-
287
- <div class="flex items-center gap-3">
288
- {# Mobile hamburger #}
289
- <button @click="sidebarOpen = true"
290
- class="md:hidden p-2 rounded-lg text-[#c7c4d7]
291
- hover:text-[#dae2fd] hover:bg-white/[0.05]
292
- transition-colors"
293
- aria-label="Open navigation"
294
- aria-expanded="false"
295
- :aria-expanded="sidebarOpen">
296
- <span class="material-symbols-outlined"
297
- style="font-size:22px; font-variation-settings: 'FILL' 0, 'wght' 300;"
298
- aria-hidden="true">menu</span>
299
- </button>
300
-
301
- {# Mobile brand (hidden on desktop) #}
302
- <div class="md:hidden flex items-center gap-2">
303
- <span class="material-symbols-outlined text-[#c0c1ff]"
304
- style="font-size:22px; font-variation-settings: 'FILL' 1, 'wght' 400;"
305
- aria-hidden="true">psychology</span>
306
- <span class="font-semibold text-[#dae2fd]">SentimentAI</span>
307
- </div>
308
-
309
- {# Desktop page title (hidden on mobile) #}
310
- <h2 class="hidden md:block text-body-md font-medium text-[#c7c4d7]">
311
- {{ page_title }}
312
- </h2>
313
- </div>
314
-
315
- {# Right action group #}
316
- <div class="flex items-center gap-2">
317
- <button class="p-2 rounded-lg text-[#c7c4d7]
318
- hover:text-[#dae2fd] hover:bg-white/[0.05]
319
- transition-colors"
320
- aria-label="Notifications">
321
- <span class="material-symbols-outlined"
322
- style="font-size:20px; font-variation-settings: 'FILL' 0, 'wght' 300;"
323
- aria-hidden="true">notifications</span>
324
- </button>
325
-
326
- <a href="/docs"
327
- target="_blank"
328
- rel="noopener noreferrer"
329
- class="hidden sm:flex items-center gap-1.5 px-3 py-1.5
330
- border border-white/[0.12] rounded-lg
331
- font-mono text-[12px] text-[#c7c4d7]
332
- hover:text-[#dae2fd] hover:border-white/25
333
- transition-colors duration-150">
334
- <span class="material-symbols-outlined"
335
- style="font-size:14px; font-variation-settings: 'FILL' 0, 'wght' 300;"
336
- aria-hidden="true">api</span>
337
- API Docs
338
- </a>
339
-
340
- {# Avatar placeholder #}
341
- <div class="w-8 h-8 rounded-full bg-[#222a3d]
342
- border border-white/[0.12]
343
- flex items-center justify-center
344
- text-[#c7c4d7] select-none"
345
- role="img"
346
- aria-label="User avatar">
347
- <span class="material-symbols-outlined"
348
- style="font-size:18px; font-variation-settings: 'FILL' 0, 'wght' 300;"
349
- aria-hidden="true">person</span>
350
- </div>
351
- </div>
352
- </header>
353
-
354
- {# Page content area #}
355
- <main class="flex-1 pt-16 overflow-y-auto" id="main-content">
356
- <div class="max-w-[1280px] mx-auto px-lg md:px-3xl py-xl md:py-2xl">
357
- {% block content %}{% endblock %}
358
- </div>
359
- </main>
360
-
361
- {# Footer #}
362
- <footer class="border-t border-white/[0.05] py-4 text-center font-mono text-label-sm text-[#c7c4d7]/50">
363
- SentimentAI — Multilingual ABSA Dashboard
364
- </footer>
365
-
366
- </div>{# /main area #}
367
- </div>{# /page wrapper #}
368
-
369
- {# ── JavaScript ────────────────────────────────────────────────────────────── #}
370
-
371
- {#
372
- Alpine.js state — defined BEFORE the defer script so appState() is in scope
373
- when Alpine.js initialises after DOMContentLoaded.
374
- #}
375
- <script>
376
- /**
377
- * appState()
378
- * ----------
379
- * Root Alpine.js component mounted on <body>.
380
- *
381
- * Responsibilities:
382
- * - sidebarOpen : mobile sidebar toggle state
383
- * - toasts : notification queue for HTMX errors and success messages
384
- * - addToast() : push a message (called via @notify.window event)
385
- *
386
- * HTMX events are converted to CustomEvents ("notify") which Alpine.js
387
- * listens for via @notify.window. This keeps HTMX and Alpine.js decoupled.
388
- */
389
- function appState() {
390
- return {
391
- sidebarOpen: false,
392
- toasts: [],
393
-
394
- addToast(msg, type = 'info') {
395
- const toast = { id: Date.now() + Math.random(), msg, type, visible: true };
396
- this.toasts.push(toast);
397
- // Auto-dismiss after 3.5 s
398
- setTimeout(() => {
399
- toast.visible = false;
400
- // Remove from array after fade-out completes
401
- setTimeout(() => {
402
- this.toasts = this.toasts.filter(t => t.id !== toast.id);
403
- }, 200);
404
- }, 3500);
405
- },
406
- };
407
- }
408
-
409
- // ── HTMX → notify bridge ─────────────────────────────────────────────────
410
- // Translate HTMX lifecycle events into the "notify" CustomEvent so Alpine.js
411
- // can display them without tightly coupling HTMX handlers to Alpine components.
412
-
413
- document.addEventListener('htmx:responseError', function (e) {
414
- let msg = 'Request failed. Please try again.';
415
- try {
416
- const body = JSON.parse(e.detail.xhr.responseText);
417
- msg = body.detail || msg;
418
- } catch (_) { /* not JSON — use default */ }
419
- window.dispatchEvent(new CustomEvent('notify', { detail: { msg, type: 'error' } }));
420
- });
421
-
422
- document.addEventListener('htmx:sendError', function () {
423
- window.dispatchEvent(new CustomEvent('notify', {
424
- detail: { msg: 'Network error — check your connection.', type: 'error' }
425
- }));
426
- });
427
-
428
- // ── CSRF token injection for HTMX ─────────────────────────────────────────
429
- // Reads csrf_token from <meta name="csrf-token"> and attaches it as
430
- // X-CSRF-Token header on every non-GET HTMX request.
431
- document.addEventListener('htmx:configRequest', function (e) {
432
- const meta = document.querySelector('meta[name="csrf-token"]');
433
- if (meta) {
434
- e.detail.headers['X-CSRF-Token'] = meta.getAttribute('content');
435
- }
436
- });
437
- </script>
438
-
439
- {# HTMX — loaded deferred so it does not block rendering #}
440
- <script src="https://unpkg.com/htmx.org@2.0.3/dist/htmx.min.js" defer></script>
441
-
442
- {# Alpine.js v3 — loaded deferred; appState() above is already in global scope #}
443
- <script defer src="https://unpkg.com/alpinejs@3.14.1/dist/cdn.min.js"></script>
444
-
445
- {# ── Per-page extra scripts ───────────────────────────────────────────────── #}
446
- {% block scripts %}{% endblock %}
447
-
448
- </body>
449
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/macros/ui.html DELETED
@@ -1,178 +0,0 @@
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 %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/pages/batch.html DELETED
@@ -1,124 +0,0 @@
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 ──────────────────────────────────────────────────────────── #}
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 ─────────────────────────────────────── #}
85
- <section aria-label="Recent batch jobs">
86
- <h2 class="text-headline-sm text-[#dae2fd] mb-lg">Recent Batches</h2>
87
- <div class="card overflow-hidden p-0">
88
- <div class="overflow-x-auto">
89
- <table class="w-full text-left border-collapse" aria-label="Recent batch jobs">
90
- <thead>
91
- <tr class="border-b border-white/[0.08] bg-[#222a3d]/50">
92
- <th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-xl">Filename</th>
93
- <th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-lg">Rows</th>
94
- <th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-lg">Status</th>
95
- <th scope="col" class="font-mono text-label-sm text-[#c7c4d7] py-3 px-xl text-right">Date</th>
96
- </tr>
97
- </thead>
98
- <tbody class="divide-y divide-white/[0.05]">
99
- {# Phase 2: static mock rows matching React's Analytics.jsx mock data #}
100
- {% for row in [
101
- {'name': 'q3_customer_feedback.csv', 'rows': '4,250', 'status': 'completed', 'date': 'Today, 14:32'},
102
- {'name': 'product_launch_tweets.csv', 'rows': '8,912', 'status': 'processing', 'date': 'Today, 14:15'},
103
- {'name': 'corrupted_export_09.csv', 'rows': '—', 'status': 'failed', 'date': 'Yesterday'},
104
- ] %}
105
- <tr class="hover:bg-white/[0.03] transition-colors">
106
- <td class="py-3 px-xl">
107
- <div class="flex items-center gap-2 text-body-md text-[#dae2fd]">
108
- {{ ms_icon('description', size=16, cls='text-[#c7c4d7]') }}
109
- {{ row.name }}
110
- </div>
111
- </td>
112
- <td class="py-3 px-lg font-mono text-body-sm text-[#c7c4d7]">{{ row.rows }}</td>
113
- <td class="py-3 px-lg">{{ status_badge(row.status) }}</td>
114
- <td class="py-3 px-xl text-right font-mono text-body-sm text-[#c7c4d7]">{{ row.date }}</td>
115
- </tr>
116
- {% endfor %}
117
- </tbody>
118
- </table>
119
- </div>
120
- </div>
121
- </section>
122
-
123
- </div>
124
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/pages/monitor.html DELETED
@@ -1,161 +0,0 @@
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
- {% include "partials/monitor_health.html" %}
60
- </div>
61
-
62
- {# Model Configuration card #}
63
- <div class="lg:col-span-8 card">
64
- <div class="flex items-center gap-3 mb-xl">
65
- <div class="p-2.5 rounded-lg bg-[#c0c1ff]/10">
66
- {{ ms_icon('memory', size=24, cls='text-[#c0c1ff]') }}
67
- </div>
68
- <div>
69
- <h2 class="text-title-lg text-[#dae2fd]">Model Configuration</h2>
70
- <p class="font-mono text-label-sm text-[#c7c4d7]">Loaded ONNX Graphs</p>
71
- </div>
72
- </div>
73
- <div class="grid grid-cols-2 gap-md">
74
- {{ info_row('Architecture', 'XLM-RoBERTa (INT8)') }}
75
- {{ info_row('Supported Languages', 'English, Hindi, Hinglish') }}
76
- <div class="bg-[#0b1326] rounded-lg p-3">
77
- <p class="font-mono text-[11px] text-[#c7c4d7] mb-1 uppercase tracking-wide">Aspect Extraction</p>
78
- {{ loaded_badge() }}
79
- </div>
80
- <div class="bg-[#0b1326] rounded-lg p-3">
81
- <p class="font-mono text-[11px] text-[#c7c4d7] mb-1 uppercase tracking-wide">Sentiment Classification</p>
82
- {{ loaded_badge() }}
83
- </div>
84
- </div>
85
- </div>
86
- </div>
87
-
88
- {# ── Performance metrics (stat cards) ─────────────────────────────────────── #}
89
- <div>
90
- <h2 class="text-headline-sm text-[#dae2fd] mb-lg">Performance Metrics</h2>
91
- <div class="grid grid-cols-1 sm:grid-cols-3 gap-xl">
92
-
93
- {# Macro-style stat card — defined inline since it's used only here #}
94
- {% for stat in [
95
- {'icon': 'database', 'label': 'Total Requests Today', 'value': '12.4k', 'sub': '↑ 8% vs yesterday', 'color': '#c0c1ff', 'positive': true},
96
- {'icon': 'bolt', 'label': 'Avg Latency (P95)', 'value': '145ms', 'sub': 'Well within SLA', 'color': '#4edea3', 'positive': true},
97
- {'icon': 'warning', 'label': 'Error Rate', 'value': '0.2%', 'sub': 'Last 24 hours', 'color': '#ffb4ab', 'positive': false},
98
- ] %}
99
- <div class="stat-card group">
100
- {# Sparkline gradient background #}
101
- <div class="absolute bottom-0 left-0 w-full h-16 opacity-40 group-hover:opacity-70
102
- transition-opacity pointer-events-none">
103
- <svg viewBox="0 0 100 40" class="w-full h-full" preserveAspectRatio="none" aria-hidden="true">
104
- <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' }}"
105
- fill="{{ stat.color }}22"
106
- stroke="{{ stat.color }}"
107
- stroke-width="1.5"
108
- vector-effect="non-scaling-stroke" />
109
- </svg>
110
- </div>
111
- <div class="relative z-10">
112
- <div class="flex items-center gap-2 mb-lg">
113
- <span class="material-symbols-outlined text-2xl"
114
- style="color: {{ stat.color }}; font-variation-settings: 'FILL' 1, 'wght' 400;"
115
- aria-hidden="true">{{ stat.icon }}</span>
116
- <span class="font-mono text-label-md text-[#c7c4d7] uppercase tracking-wider">{{ stat.label }}</span>
117
- </div>
118
- <p class="text-display text-[#dae2fd] font-semibold">{{ stat.value }}</p>
119
- <p class="font-mono text-label-sm text-[#c7c4d7] mt-1">{{ stat.sub }}</p>
120
- </div>
121
- </div>
122
- {% endfor %}
123
-
124
- </div>
125
- </div>
126
-
127
- {# ── Recent endpoint activity ──────────────────────────────────────────────── #}
128
- <div class="card">
129
- <h2 class="text-title-lg text-[#dae2fd] mb-lg">Recent Endpoint Activity</h2>
130
- <div class="space-y-2" role="list" aria-label="Recent API requests">
131
- {% for req in [
132
- {'method': 'POST', 'path': '/predict', 'status': 200, 'time': '3.5ms', 'ago': '2s ago'},
133
- {'method': 'GET', 'path': '/health', 'status': 200, 'time': '0.8ms', 'ago': '5s ago'},
134
- {'method': 'POST', 'path': '/batch', 'status': 202, 'time': '12.1ms', 'ago': '1m ago'},
135
- {'method': 'GET', 'path': '/status/abc12', 'status': 200, 'time': '1.2ms', 'ago': '1m ago'},
136
- {'method': 'POST', 'path': '/predict', 'status': 500, 'time': '23ms', 'ago': '3m ago'},
137
- ] %}
138
- <div class="flex items-center gap-4 py-2 px-md rounded-lg hover:bg-white/[0.03] transition-colors"
139
- role="listitem">
140
- <span class="font-mono text-label-sm w-10 flex-shrink-0
141
- {{ 'text-[#c0c1ff]' if req.method == 'POST' else 'text-[#4edea3]' }}">
142
- {{ req.method }}
143
- </span>
144
- <span class="font-mono text-body-sm text-[#dae2fd] flex-1 truncate">{{ req.path }}</span>
145
- <span class="font-mono text-label-sm w-10 text-right flex-shrink-0
146
- {{ 'text-[#ffb4ab]' if req.status >= 500 else ('text-[#f5c542]' if req.status >= 400 else 'text-[#4edea3]') }}">
147
- {{ req.status }}
148
- </span>
149
- <span class="font-mono text-label-sm text-[#c7c4d7] w-14 text-right flex-shrink-0">{{ req.time }}</span>
150
- <span class="hidden sm:block font-mono text-label-sm text-[#c7c4d7]/50 w-16 text-right flex-shrink-0">
151
- {{ req.ago }}
152
- </span>
153
- </div>
154
- {% endfor %}
155
- </div>
156
-
157
-
158
- </div>
159
-
160
- </div>
161
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/pages/predict.html DELETED
@@ -1,107 +0,0 @@
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
- <form class="card-low flex flex-col h-full min-h-[420px] group"
36
- hx-post="/predict/fragment"
37
- hx-target="#result-panel"
38
- x-data="{ text: '' }">
39
-
40
- <div class="flex justify-between items-center mb-lg pb-md border-b border-white/[0.06]">
41
- <h3 class="font-mono text-label-md text-[#c7c4d7] uppercase tracking-wider">
42
- Analyze Input
43
- </h3>
44
- <div class="flex items-center gap-2">
45
- <label for="lang-select" class="font-mono text-label-sm text-[#c7c4d7]">Language</label>
46
- <select id="lang-select" name="language" class="input-base py-1 text-body-sm" style="width:auto;">
47
- <option value="auto">Auto-detect</option>
48
- <option value="en">English</option>
49
- <option value="hi">Hindi</option>
50
- </select>
51
- </div>
52
- </div>
53
-
54
- <div class="flex-1 flex flex-col mb-lg">
55
- <label for="review-text" class="font-mono text-label-sm text-[#c7c4d7] mb-2">Source Text</label>
56
- <textarea id="review-text"
57
- name="text"
58
- x-model="text"
59
- required
60
- class="input-base flex-1 min-h-[260px] resize-none leading-relaxed"
61
- placeholder="Paste your review, article, or social media post here…"
62
- @keydown.meta.enter="$el.form.dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}))">
63
- </textarea>
64
- <div class="flex justify-between mt-2">
65
- <span class="font-mono text-label-sm text-[#c7c4d7]/50">⌘ Enter to analyze</span>
66
- <span class="font-mono text-label-sm text-[#c7c4d7]/50" x-text="text.length + ' / 512'">0 / 512</span>
67
- </div>
68
- </div>
69
-
70
- <button type="submit" class="btn-primary" :disabled="text.length === 0">
71
- <div class="flex items-center gap-2 group-[.htmx-request]:hidden">
72
- {{ ms_icon('bolt', size=16) }}
73
- <span>Analyze</span>
74
- </div>
75
- <div class="htmx-indicator items-center gap-2">
76
- <span class="material-symbols-outlined animate-spin" style="font-size:16px;">autorenew</span>
77
- <span>Analyzing...</span>
78
- </div>
79
- </button>
80
-
81
- </form>
82
- </div>
83
-
84
- {# ── Right: Results panel ─────────────────────────────────────────────── #}
85
- <div class="lg:col-span-5 flex flex-col">
86
- <div class="glass-panel rounded-xl flex flex-col h-full min-h-[420px] p-xl">
87
-
88
- <div class="flex justify-between items-center mb-lg pb-md border-b border-white/[0.06]">
89
- <h3 class="font-mono text-label-md text-[#c7c4d7] uppercase tracking-wider">
90
- Analysis Results
91
- </h3>
92
- </div>
93
-
94
- {# Phase 2 placeholder — replaced by HTMX partial in Phase 3 #}
95
- <div id="result-panel" class="flex-1 flex">
96
- {{ empty_state('psychology', 'Enter a review and click Analyze to see results') }}
97
- </div>
98
-
99
- </div>
100
- </div>
101
-
102
- </div>{# /grid #}
103
-
104
-
105
-
106
- </div>
107
- {% endblock %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/partials/batch_charts.html DELETED
@@ -1,114 +0,0 @@
1
- {% if error %}
2
- <div class="card bg-error/10 border border-error/20 p-md rounded-lg text-center">
3
- <p class="text-error font-medium">Failed to load charts: {{ error }}</p>
4
- </div>
5
- {% else %}
6
- <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
7
- <div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-xl animate-fade-in" id="batch-charts-container">
8
-
9
- {# Aspect Heatmap (Stacked Bar) #}
10
- <div class="xl:col-span-2 bg-[#222a3d] rounded-xl shadow-sm border border-white/[0.08] p-6 h-96">
11
- <h3 class="text-sm font-semibold text-[#dae2fd] mb-4">Top Aspects by Sentiment</h3>
12
- <div class="relative w-full h-[300px]">
13
- <canvas id="aspectChart"></canvas>
14
- </div>
15
- </div>
16
-
17
- {# Language Pie #}
18
- <div class="bg-[#222a3d] rounded-xl shadow-sm border border-white/[0.08] p-6 h-96">
19
- <h3 class="text-sm font-semibold text-[#dae2fd] mb-4">Language Distribution</h3>
20
- <div class="relative w-full h-[300px]">
21
- <canvas id="languageChart"></canvas>
22
- </div>
23
- </div>
24
-
25
- {# Sentiment Chart (Line) #}
26
- <div class="lg:col-span-2 xl:col-span-3 bg-[#222a3d] rounded-xl shadow-sm border border-white/[0.08] p-6 h-96">
27
- <h3 class="text-sm font-semibold text-[#dae2fd] mb-4">Sentiment Over Time (Row Chunks)</h3>
28
- <div class="relative w-full h-[300px]">
29
- <canvas id="sentimentChart"></canvas>
30
- </div>
31
- </div>
32
-
33
- </div>
34
-
35
- <script>
36
- (function() {
37
- // Shared styling for dark mode
38
- Chart.defaults.color = '#c7c4d7';
39
- Chart.defaults.borderColor = 'rgba(255, 255, 255, 0.08)';
40
-
41
- // Data passed from backend
42
- const rawAspect = {{ aspect_heatmap | safe }};
43
- const rawLang = {{ language_pie | safe }};
44
- const rawSent = {{ sentiment_chart | safe }};
45
-
46
- // 1. Aspect Stacked Bar Chart (Horizontal)
47
- const aspectCtx = document.getElementById('aspectChart');
48
- if (aspectCtx && rawAspect.length > 0) {
49
- new Chart(aspectCtx, {
50
- type: 'bar',
51
- data: {
52
- labels: rawAspect.map(d => d.aspect),
53
- datasets: [
54
- { label: 'Positive', data: rawAspect.map(d => d.positive), backgroundColor: '#10B981' },
55
- { label: 'Negative', data: rawAspect.map(d => d.negative), backgroundColor: '#EF4444' },
56
- { label: 'Neutral', data: rawAspect.map(d => d.neutral), backgroundColor: '#6B7280' },
57
- { label: 'Conflict', data: rawAspect.map(d => d.conflict), backgroundColor: '#F59E0B' }
58
- ]
59
- },
60
- options: {
61
- indexAxis: 'y',
62
- responsive: true,
63
- maintainAspectRatio: false,
64
- scales: { x: { stacked: true }, y: { stacked: true } }
65
- }
66
- });
67
- }
68
-
69
- // 2. Language Pie Chart
70
- const langCtx = document.getElementById('languageChart');
71
- if (langCtx && rawLang.length > 0) {
72
- new Chart(langCtx, {
73
- type: 'pie',
74
- data: {
75
- labels: rawLang.map(d => d.name),
76
- datasets: [{
77
- data: rawLang.map(d => d.value),
78
- backgroundColor: ['#3B82F6', '#F97316', '#10B981', '#8B5CF6'],
79
- borderWidth: 0
80
- }]
81
- },
82
- options: {
83
- responsive: true,
84
- maintainAspectRatio: false,
85
- plugins: {
86
- legend: { position: 'bottom' }
87
- }
88
- }
89
- });
90
- }
91
-
92
- // 3. Sentiment Line Chart
93
- const sentCtx = document.getElementById('sentimentChart');
94
- if (sentCtx && rawSent.length > 0) {
95
- new Chart(sentCtx, {
96
- type: 'line',
97
- data: {
98
- labels: rawSent.map(d => d.name),
99
- datasets: [
100
- { label: 'Positive', data: rawSent.map(d => d.positive), borderColor: '#10B981', backgroundColor: '#10B981', tension: 0.3 },
101
- { label: 'Negative', data: rawSent.map(d => d.negative), borderColor: '#EF4444', backgroundColor: '#EF4444', tension: 0.3 },
102
- { label: 'Neutral', data: rawSent.map(d => d.neutral), borderColor: '#6B7280', backgroundColor: '#6B7280', tension: 0.3 },
103
- { label: 'Conflict', data: rawSent.map(d => d.conflict), borderColor: '#F59E0B', backgroundColor: '#F59E0B', tension: 0.3 }
104
- ]
105
- },
106
- options: {
107
- responsive: true,
108
- maintainAspectRatio: false
109
- }
110
- });
111
- }
112
- })();
113
- </script>
114
- {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/partials/batch_progress.html DELETED
@@ -1,51 +0,0 @@
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
- {% elif job.status == 'completed' %}
43
- <div class="mt-8" hx-get="/batch/charts/{{ job.job_id }}" hx-trigger="load" hx-swap="innerHTML">
44
- <div class="flex items-center justify-center gap-2 text-[#c7c4d7]/70 font-mono text-sm py-8 animate-pulse">
45
- <span class="material-symbols-outlined animate-spin" style="font-size:18px;">analytics</span>
46
- Generating charts...
47
- </div>
48
- </div>
49
- {% endif %}
50
- {% endif %}
51
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/partials/monitor_health.html DELETED
@@ -1,19 +0,0 @@
1
- {% from "macros/ui.html" import health_chip %}
2
-
3
- <div id="health-status"
4
- hx-get="/monitor/health-partial"
5
- hx-trigger="every 30s"
6
- hx-swap="outerHTML"
7
- class="flex items-center gap-3 pt-lg border-t border-white/[0.06]">
8
- <span class="text-body-md text-[#c7c4d7]">Current state:</span>
9
-
10
- {% if error %}
11
- <span class="badge-error">
12
- <span class="w-2 h-2 rounded-full bg-[#ffb4ab] animate-pulse flex-shrink-0"></span>
13
- Service Unavailable
14
- </span>
15
- {% else %}
16
- {{ health_chip(health.status == 'ok') }}
17
- {% endif %}
18
- <span class="htmx-indicator ml-2 text-[#c7c4d7]/70 font-mono text-label-sm">Updating...</span>
19
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/templates/partials/predict_result.html DELETED
@@ -1,101 +0,0 @@
1
- {% from "macros/ui.html" import ms_icon, sentiment_badge, empty_state %}
2
-
3
- {% if error %}
4
- <div class="flex flex-col items-center justify-center gap-4 py-16 text-[#ffb4ab]">
5
- {{ ms_icon('error', size=48, cls='opacity-80') }}
6
- <p class="text-sm font-medium">{{ error }}</p>
7
- </div>
8
- {% elif result %}
9
- {# Macro to highlight text based on aspect positions #}
10
- {% macro render_annotated_text(text, aspects) %}
11
- {#
12
- For Phase 3 we do a simplified highlight.
13
- In a real template, we'd slice the text by start_pos/end_pos.
14
- For now, we just print the text, as doing complex string slicing in Jinja is hard.
15
- Wait, we can pass an 'annotated_text' pre-computed from the router, but the prompt says "do NOT duplicate business logic".
16
- Let's just output the text, or if possible, use JS or a simple replace.
17
- Actually, the user expects "highlight-positive" etc. from the CSS we wrote in Phase 2.
18
- #}
19
- <div class="text-body-lg text-[#dae2fd] leading-relaxed whitespace-pre-wrap">{{ text }}</div>
20
- {% endmacro %}
21
-
22
- <div class="flex flex-col h-full w-full animate-fade-in">
23
-
24
- {# ── Header metrics ── #}
25
- <div class="flex justify-between items-center mb-md">
26
- <div class="flex items-center gap-4">
27
- <div class="flex items-center gap-1.5 font-mono text-label-sm text-[#c7c4d7]">
28
- {{ ms_icon('language', size=16) }}
29
- <span>{{ result.detected_language | upper }}</span>
30
- </div>
31
- <div class="flex items-center gap-1.5 font-mono text-label-sm text-[#c7c4d7]">
32
- {{ ms_icon('timer', size=16) }}
33
- <span>{{ result.processing_time_ms | round }}ms</span>
34
- </div>
35
- </div>
36
- </div>
37
-
38
- {# ── Annotated Text ── #}
39
- <div class="p-md bg-[#0b1326] rounded-lg border border-white/[0.06] mb-lg">
40
- <div class="text-body-lg text-[#dae2fd] leading-relaxed whitespace-pre-wrap" id="annotated-text-container">{{ result.text }}</div>
41
- </div>
42
-
43
- {# ── Aspects List ── #}
44
- <div>
45
- <h4 class="font-mono text-label-sm text-[#c7c4d7] mb-3 uppercase tracking-wider">Detected Aspects</h4>
46
- {% if result.aspects %}
47
- <div class="space-y-2">
48
- {% for aspect in result.aspects %}
49
- <div class="flex items-center justify-between p-3 rounded-lg bg-[#222a3d] border border-white/[0.04]">
50
- <span class="text-body-md text-[#dae2fd] font-medium">{{ aspect.aspect }}</span>
51
- <div class="flex items-center gap-4">
52
- <span class="font-mono text-label-sm text-[#c7c4d7]/70">conf: {{ "%.2f"|format(aspect.confidence) }}</span>
53
- {{ sentiment_badge(aspect.sentiment) }}
54
- </div>
55
- </div>
56
- {% endfor %}
57
- </div>
58
- {% else %}
59
- <div class="p-4 rounded-lg border border-dashed border-white/[0.14] text-center">
60
- <p class="text-body-sm text-[#c7c4d7]/70">No aspects detected in this text.</p>
61
- </div>
62
- {% endif %}
63
- </div>
64
-
65
- {# ── Client-side Text Highlighting ── #}
66
- {# Uses DOM API to safely highlight aspect spans — never innerHTML with user data #}
67
- <script>
68
- (function() {
69
- const container = document.getElementById('annotated-text-container');
70
- const rawText = container.textContent;
71
- const aspects = {{ result.aspects | tojson }};
72
-
73
- if (!aspects || aspects.length === 0) return;
74
-
75
- // Clear container and rebuild with DOM nodes
76
- container.textContent = '';
77
- const sorted = [...aspects].sort((a, b) => a.start - b.start);
78
- let cursor = 0;
79
-
80
- sorted.forEach((asp) => {
81
- if (asp.start > cursor) {
82
- container.appendChild(document.createTextNode(rawText.slice(cursor, asp.start)));
83
- }
84
- const span = document.createElement('span');
85
- span.className = 'highlight-' + asp.sentiment;
86
- span.textContent = rawText.slice(asp.start, asp.end);
87
- span.title = asp.sentiment + ' \u00B7 ' + Math.round(asp.confidence * 100) + '%';
88
- container.appendChild(span);
89
- cursor = asp.end;
90
- });
91
-
92
- if (cursor < rawText.length) {
93
- container.appendChild(document.createTextNode(rawText.slice(cursor)));
94
- }
95
- })();
96
- </script>
97
-
98
- </div>
99
- {% else %}
100
- {{ empty_state('psychology', 'Enter a review and click Analyze to see results') }}
101
- {% endif %}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
docker/Dockerfile CHANGED
@@ -2,14 +2,16 @@
2
  FROM python:3.11-slim AS builder
3
 
4
  WORKDIR /app
5
- COPY requirements.txt .
 
 
6
 
7
  RUN apt-get update && apt-get install -y --no-install-recommends \
8
  build-essential \
9
  git \
10
  && rm -rf /var/lib/apt/lists/*
11
 
12
- RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
13
 
14
  # Stage 2: Runtime
15
  FROM python:3.11-slim
@@ -20,13 +22,15 @@ WORKDIR /app
20
  COPY --from=builder /install /usr/local
21
 
22
  # Copy application code
23
- COPY app /app/app
 
24
  COPY scripts /app/scripts
25
- COPY absa /app/absa
26
  COPY docker /app/docker
27
  COPY .env.example /app/.env.example
28
  # .env is injected via docker-compose environment vars — no need to COPY it
29
 
 
 
30
  # Add a non-root user
31
  RUN adduser --disabled-password --gecos "" absauser \
32
  && chown -R absauser /app
@@ -35,4 +39,4 @@ USER absauser
35
 
36
  EXPOSE 8000
37
 
38
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
 
2
  FROM python:3.11-slim AS builder
3
 
4
  WORKDIR /app
5
+ COPY pyproject.toml .
6
+ COPY src ./src
7
+ COPY api ./api
8
 
9
  RUN apt-get update && apt-get install -y --no-install-recommends \
10
  build-essential \
11
  git \
12
  && rm -rf /var/lib/apt/lists/*
13
 
14
+ RUN pip install --no-cache-dir --prefix=/install .
15
 
16
  # Stage 2: Runtime
17
  FROM python:3.11-slim
 
22
  COPY --from=builder /install /usr/local
23
 
24
  # Copy application code
25
+ COPY api /app/api
26
+ COPY src /app/src
27
  COPY scripts /app/scripts
 
28
  COPY docker /app/docker
29
  COPY .env.example /app/.env.example
30
  # .env is injected via docker-compose environment vars — no need to COPY it
31
 
32
+ ENV PYTHONPATH=/app/src
33
+
34
  # Add a non-root user
35
  RUN adduser --disabled-password --gecos "" absauser \
36
  && chown -R absauser /app
 
39
 
40
  EXPOSE 8000
41
 
42
+ CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
docker/Dockerfile.prod CHANGED
@@ -1,17 +1,19 @@
1
  FROM python:3.11-slim as builder
2
  WORKDIR /app
3
- COPY requirements.txt .
4
- RUN pip install --no-cache-dir -r requirements.txt
 
 
5
 
6
  FROM python:3.11-slim as runtime
7
  WORKDIR /app
8
  COPY --from=builder /usr/local/lib/python3.11 /usr/local/lib/python3.11
9
  COPY --from=builder /usr/local/bin /usr/local/bin
10
- COPY app/ ./app/
11
- COPY absa/ ./absa/
12
- ENV PYTHONPATH=/app
13
  ENV MODEL_SOURCE=huggingface_hub
14
  RUN useradd -m appuser && chown -R appuser /app
15
  USER appuser
16
  EXPOSE 8000
17
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
 
1
  FROM python:3.11-slim as builder
2
  WORKDIR /app
3
+ COPY pyproject.toml .
4
+ COPY src ./src
5
+ COPY api ./api
6
+ RUN pip install --no-cache-dir .
7
 
8
  FROM python:3.11-slim as runtime
9
  WORKDIR /app
10
  COPY --from=builder /usr/local/lib/python3.11 /usr/local/lib/python3.11
11
  COPY --from=builder /usr/local/bin /usr/local/bin
12
+ COPY api/ ./api/
13
+ COPY src/ ./src/
14
+ ENV PYTHONPATH=/app/src
15
  ENV MODEL_SOURCE=huggingface_hub
16
  RUN useradd -m appuser && chown -R appuser /app
17
  USER appuser
18
  EXPOSE 8000
19
+ CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
docker/docker-compose.yml CHANGED
@@ -25,7 +25,7 @@ services:
25
  context: ../
26
  dockerfile: Dockerfile
27
  container_name: absa-worker
28
- command: ["celery", "-A", "app.tasks", "worker", "--loglevel=info"]
29
  environment:
30
  - DATABASE_URL=${DATABASE_URL}
31
  - REDIS_URL=${REDIS_URL}
 
25
  context: ../
26
  dockerfile: Dockerfile
27
  container_name: absa-worker
28
+ command: ["celery", "-A", "api.tasks", "worker", "--loglevel=info"]
29
  environment:
30
  - DATABASE_URL=${DATABASE_URL}
31
  - REDIS_URL=${REDIS_URL}
docs/HTMX_MIGRATION.md DELETED
@@ -1,92 +0,0 @@
1
- # HTMX Migration
2
-
3
- ## Summary
4
-
5
- The Streamlit frontend (`streamlit_app/`) has been fully replaced with an HTMX + Jinja2 frontend served directly by the FastAPI backend. This eliminates the separate Streamlit server, reduces resource usage, and provides a single unified server for both UI and API.
6
-
7
- ## What Changed
8
-
9
- | Before | After |
10
- |--------|-------|
11
- | Streamlit frontend at `streamlit_app/` | HTMX + Jinja2 at `api/app/templates/` |
12
- | Two servers: FastAPI (8000) + Streamlit (8501) | Single FastAPI server (8000) |
13
- | `streamlit run streamlit_app/Home.py` | `uvicorn app.main:app --reload` |
14
- | Plotly for charts | Chart.js (client-side rendering) |
15
- | Streamlit state management | Alpine.js + HTMX for interactivity |
16
-
17
- ## Architecture
18
-
19
- ```
20
- User Browser
21
- ↕ HTMX / Alpine.js
22
- FastAPI (port 8000)
23
- ├── /predict → Predict page (HTML via Jinja2)
24
- ├── /batch → Batch upload page (HTML)
25
- ├── /monitor → System monitor page (HTML)
26
- ├── /docs → Swagger UI (unchanged)
27
- ├── /api/predict → JSON API (unchanged)
28
- ├── /api/batch → JSON API (unchanged)
29
- ├── /predict/fragment → HTMX fragment (HTML partial)
30
- ├── /batch/fragment → HTMX fragment (HTML partial)
31
- └── /api/batch/progress/{job_id} → SSE endpoint
32
- ```
33
-
34
- ## Frontend Stack
35
-
36
- - **HTMX 2.0.3** - AJAX, CSS transitions, WebSocket/SSE
37
- - **Alpine.js 3.14** - Reactive UI state (toasts, sidebar)
38
- - **Tailwind CSS (Play CDN)** - Utility-first CSS
39
- - **Chart.js** - Client-side charts (batch results)
40
- - **Material Symbols** - Icon font
41
- - **itsdangerous** - CSRF protection
42
-
43
- ## CSRF Protection
44
-
45
- All HTMX form endpoints require a CSRF token. The token is:
46
- - Set as an `HttpOnly` cookie on every GET response
47
- - Injected into `<meta name="csrf-token">` in `base.html`
48
- - Automatically attached to HTMX requests via `htmx:configRequest` event handler
49
- - Validated by `CSRFMiddleware` for all non-GET, non-API requests
50
-
51
- ## File Structure
52
-
53
- ```
54
- api/app/
55
- ├── templates/
56
- │ ├── base.html # Base layout with nav, sidebar, toast system
57
- │ ├── pages/
58
- │ │ ├── predict.html # Single review prediction form
59
- │ │ ├── batch.html # CSV upload + progress + results
60
- │ │ └── monitor.html # Health stats + performance metrics
61
- │ ├── partials/
62
- │ │ ├── predict_result.html # Prediction result card
63
- │ │ ├── batch_progress.html # Batch job progress bar
64
- │ │ ├── batch_charts.html # Chart.js charts
65
- │ │ └── monitor_health.html # Health status chip
66
- │ └── macros/
67
- │ └── ui.html # Reusable components (badges, icons, etc.)
68
- ├── static/
69
- │ └── css/
70
- │ └── app.css # Design system components
71
- ├── core/
72
- │ └── templates.py # Centralized Jinja2Templates instance
73
- ├── middleware/
74
- │ └── csrf.py # CSRF protection middleware
75
- └── main.py # FastAPI app entry point
76
- ```
77
-
78
- ## Deleted Files
79
-
80
- - `streamlit_app/` (entire directory)
81
- - `config/docker/Dockerfile.streamlit`
82
- - Streamlit service in `config/docker/docker-compose.yml`
83
- - Streamlit dependency from `requirements.txt`
84
- - Plotly dependency from `requirements.txt`
85
-
86
- ## Verification
87
-
88
- - All page routes return HTML at `/predict`, `/batch`, `/monitor`
89
- - JSON API endpoints remain at `/api/predict`, `/api/batch`
90
- - Swagger UI at `/docs` is unchanged
91
- - HTMX endpoints return HTML fragments (no page reload)
92
- - SSE endpoint for live batch progress at `/api/batch/progress/{job_id}`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dvc.yaml CHANGED
@@ -1,11 +1,11 @@
1
  stages:
2
  preprocess_semeval:
3
- cmd: PYTHONPATH=. python absa/data/dataset.py
4
  deps:
5
- - absa/data/dataset.py
6
- - absa/data/preprocess.py
7
- - absa/data/lang_detect.py
8
- - absa/data/transliterate.py
9
  - data/raw/semeval_restaurants
10
  - data/raw/semeval_laptops
11
  outs:
@@ -13,12 +13,12 @@ stages:
13
  - data/processed/semeval_test.jsonl
14
 
15
  preprocess_hindi:
16
- cmd: PYTHONPATH=. python absa/data/hindi_loader.py
17
  deps:
18
- - absa/data/hindi_loader.py
19
- - absa/data/preprocess.py
20
- - absa/data/lang_detect.py
21
- - absa/data/transliterate.py
22
  - data/raw/amazon_hindi/hindi_sentiment.jsonl
23
  outs:
24
  - data/processed/amazon_hindi.jsonl
 
1
  stages:
2
  preprocess_semeval:
3
+ cmd: PYTHONPATH=src python -m absa.data.dataset
4
  deps:
5
+ - src/absa/data/dataset.py
6
+ - src/absa/data/preprocess.py
7
+ - src/absa/data/lang_detect.py
8
+ - src/absa/data/transliterate.py
9
  - data/raw/semeval_restaurants
10
  - data/raw/semeval_laptops
11
  outs:
 
13
  - data/processed/semeval_test.jsonl
14
 
15
  preprocess_hindi:
16
+ cmd: PYTHONPATH=src python -m absa.data.hindi_loader
17
  deps:
18
+ - src/absa/data/hindi_loader.py
19
+ - src/absa/data/preprocess.py
20
+ - src/absa/data/lang_detect.py
21
+ - src/absa/data/transliterate.py
22
  - data/raw/amazon_hindi/hindi_sentiment.jsonl
23
  outs:
24
  - data/processed/amazon_hindi.jsonl
frontend/Home.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multilingual ABSA — Streamlit entry point.
2
+
3
+ Navigation split:
4
+ • Analysis → the only screen regular users see (input comment → results).
5
+ • Admin → application status, batch analytics, system monitor.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import streamlit as st
11
+ from ui import apply_theme
12
+
13
+ st.set_page_config(
14
+ page_title="Multilingual ABSA",
15
+ page_icon="🌍",
16
+ layout="wide",
17
+ initial_sidebar_state="expanded",
18
+ )
19
+
20
+ apply_theme()
21
+
22
+ analyzer = st.Page(
23
+ "views/predict.py",
24
+ title="Sentiment Analyzer",
25
+ icon="💬",
26
+ url_path="predict",
27
+ default=True,
28
+ )
29
+
30
+ admin_overview = st.Page(
31
+ "views/admin/overview.py",
32
+ title="Overview",
33
+ icon="📊",
34
+ url_path="admin",
35
+ )
36
+ admin_batch = st.Page(
37
+ "views/admin/batch.py",
38
+ title="Batch Analytics",
39
+ icon="📁",
40
+ url_path="batch",
41
+ )
42
+ admin_monitor = st.Page(
43
+ "views/admin/monitor.py",
44
+ title="System Monitor",
45
+ icon="🩺",
46
+ url_path="monitor",
47
+ )
48
+
49
+ pg = st.navigation(
50
+ {
51
+ "Analysis": [analyzer],
52
+ "Admin": [admin_overview, admin_batch, admin_monitor],
53
+ },
54
+ position="sidebar",
55
+ )
56
+
57
+ pg.run()
{app → frontend}/__init__.py RENAMED
File without changes
frontend/absa_client.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HTTP client for the Multilingual ABSA FastAPI backend.
2
+
3
+ The Streamlit frontend never imports the ML pipeline directly — it talks to
4
+ the running FastAPI service (``API_BASE_URL``, default ``http://localhost:8000``)
5
+ over plain HTTP. This keeps the dashboard a thin, deployable UI layer.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from typing import Any, Optional
12
+
13
+ import httpx
14
+ import streamlit as st
15
+
16
+ API_BASE_URL: str = os.getenv("API_BASE_URL", "http://localhost:8000")
17
+
18
+ _TIMEOUT: float = float(os.getenv("API_TIMEOUT", "60"))
19
+
20
+
21
+ class APIClient:
22
+ """Thin wrapper around the ABSA REST API with Streamlit-friendly errors."""
23
+
24
+ def __init__(self, base_url: str = API_BASE_URL, transport: Optional[httpx.BaseTransport] = None) -> None:
25
+ self.base_url = base_url.rstrip("/")
26
+ self._client = httpx.Client(
27
+ base_url=self.base_url,
28
+ timeout=_TIMEOUT,
29
+ follow_redirects=True,
30
+ transport=transport,
31
+ )
32
+
33
+ # ── Helpers ────────────────────────────────────────────────────────────
34
+
35
+ def _request(self, method: str, path: str, **kwargs: Any) -> Optional[Any]:
36
+ try:
37
+ response = self._client.request(method, path, **kwargs)
38
+ response.raise_for_status()
39
+ return response.json()
40
+ except httpx.HTTPStatusError as exc:
41
+ detail = exc.response.text
42
+ st.error(f"API error ({exc.response.status_code}): {detail}")
43
+ return None
44
+ except httpx.HTTPError as exc:
45
+ st.error(f"Cannot reach the ABSA API at `{self.base_url}` — is it running?\n\n{exc}")
46
+ return None
47
+
48
+ def close(self) -> None:
49
+ self._client.close()
50
+
51
+ # ── Endpoints ──────────────────────────────────────────────────────────
52
+
53
+ def get_health(self) -> Optional[dict[str, str]]:
54
+ return self._request("GET", "/health")
55
+
56
+ def get_info(self) -> Optional[dict[str, str]]:
57
+ return self._request("GET", "/info")
58
+
59
+ def predict(self, text: str, language: str = "auto") -> Optional[dict[str, Any]]:
60
+ payload = {"text": text, "language": language if language != "auto" else None}
61
+ return self._request("POST", "/predict", json=payload)
62
+
63
+ def upload_batch(self, file: Any) -> Optional[dict[str, Any]]:
64
+ files = {"file": (file.name, file.getvalue(), "text/csv")}
65
+ return self._request("POST", "/batch", files=files)
66
+
67
+ def get_batch_status(self, job_id: str) -> Optional[dict[str, Any]]:
68
+ return self._request("GET", f"/status/{job_id}")
69
+
70
+ def download_result(self, job_id: str) -> Optional[bytes]:
71
+ """Fetch the generated CSV bytes for a completed batch job."""
72
+ try:
73
+ response = self._client.get(f"/download/{job_id}")
74
+ response.raise_for_status()
75
+ return response.content
76
+ except httpx.HTTPError as exc:
77
+ st.error(f"Failed to download results: {exc}")
78
+ return None
79
+
80
+
81
+ @st.cache_resource(show_spinner=False)
82
+ def get_client() -> APIClient:
83
+ """Return a process-wide cached API client (reused across reruns)."""
84
+ return APIClient()
frontend/ui.py ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared UI helpers for the Streamlit dashboard: theme, cards, badges."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from typing import Iterable
7
+
8
+ import streamlit as st
9
+
10
+ SENTIMENT_COLORS: dict[str, str] = {
11
+ "positive": "#16a34a",
12
+ "negative": "#dc2626",
13
+ "neutral": "#64748b",
14
+ "conflict": "#9333ea",
15
+ }
16
+
17
+ LANGUAGE_LABELS: dict[str, str] = {
18
+ "en": "English",
19
+ "hi": "Hindi",
20
+ "hinglish": "Hinglish",
21
+ "auto": "Auto-detect",
22
+ }
23
+
24
+ _SAMPLE_REVIEWS: list[dict[str, str]] = [
25
+ {
26
+ "label": "Battery + Screen (EN)",
27
+ "text": "The battery life is amazing but the screen is too dim.",
28
+ },
29
+ {
30
+ "label": "Camera & Service (EN)",
31
+ "text": "Great camera quality, though the delivery was terribly slow.",
32
+ },
33
+ {
34
+ "label": "Sound (HI)",
35
+ "text": "आवाज़ बहुत साफ़ है और बेस भी बढ़िया है।",
36
+ },
37
+ {
38
+ "label": "Hinglish Mix",
39
+ "text": "Phone ka design badhiya hai lekin battery life kharab hai.",
40
+ },
41
+ ]
42
+
43
+
44
+ def apply_theme() -> None:
45
+ """Inject custom CSS: modern gradient hero, cards, badges, spacing."""
46
+ st.markdown(
47
+ """
48
+ <style>
49
+ :root {
50
+ --absa-primary: #6366f1;
51
+ --absa-primary-2: #8b5cf6;
52
+ --absa-accent: #06b6d4;
53
+ }
54
+
55
+ /* Modern gradient hero banner */
56
+ .absa-hero {
57
+ background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 55%, #06b6d4 100%);
58
+ border-radius: 1.25rem;
59
+ padding: 2.5rem 2.5rem 2rem 2.5rem;
60
+ color: white;
61
+ margin-bottom: 1.5rem;
62
+ box-shadow: 0 10px 30px rgba(99, 102, 241, 0.35);
63
+ }
64
+ .absa-hero h1 { font-size: 2.4rem; font-weight: 800; margin: 0 0 0.4rem 0; letter-spacing: -0.02em; }
65
+ .absa-hero p { font-size: 1.05rem; opacity: 0.92; margin: 0; max-width: 46rem; }
66
+
67
+ /* Action / feature cards */
68
+ .absa-card {
69
+ background: linear-gradient(180deg, #ffffff, #f8fafc);
70
+ border: 1px solid #e2e8f0;
71
+ border-radius: 1rem;
72
+ padding: 1.35rem 1.4rem;
73
+ margin-bottom: 1rem;
74
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
75
+ transition: transform 0.15s ease, box-shadow 0.15s ease;
76
+ }
77
+ .absa-card:hover { transform: translateY(-2px); box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08); }
78
+ .absa-card .card-title { font-size: 1.15rem; font-weight: 700; color: #0f172a; margin-bottom: 0.3rem; }
79
+ .absa-card .card-desc { font-size: 0.92rem; color: #475569; margin-bottom: 0.7rem; }
80
+ .absa-card .card-icon { font-size: 1.6rem; margin-bottom: 0.4rem; }
81
+
82
+ /* Aspect result card */
83
+ .absa-result {
84
+ border: 1px solid #e2e8f0;
85
+ border-left: 6px solid #94a3b8;
86
+ border-radius: 0.9rem;
87
+ padding: 1rem 1.2rem;
88
+ margin-bottom: 0.85rem;
89
+ background: #ffffff;
90
+ }
91
+ .absa-result .aspect-name { font-weight: 700; color: #0f172a; font-size: 1.05rem; }
92
+ .absa-result .confidence { color: #64748b; font-size: 0.9rem; margin-top: 0.3rem; }
93
+
94
+ /* Sentiment badge */
95
+ .absa-badge {
96
+ display: inline-block;
97
+ padding: 0.22rem 0.8rem;
98
+ border-radius: 999px;
99
+ font-size: 0.78rem;
100
+ font-weight: 700;
101
+ letter-spacing: 0.04em;
102
+ text-transform: uppercase;
103
+ color: #fff;
104
+ }
105
+
106
+ /* Confidence bar */
107
+ .absa-bar {
108
+ height: 8px;
109
+ border-radius: 999px;
110
+ background: #e2e8f0;
111
+ overflow: hidden;
112
+ margin-top: 0.5rem;
113
+ }
114
+ .absa-bar-fill { height: 100%; border-radius: 999px; }
115
+
116
+ /* Language pill */
117
+ .absa-pill {
118
+ display: inline-block;
119
+ background: #eef2ff;
120
+ color: #4338ca;
121
+ border-radius: 999px;
122
+ padding: 0.15rem 0.7rem;
123
+ font-size: 0.8rem;
124
+ font-weight: 600;
125
+ margin-right: 0.4rem;
126
+ }
127
+
128
+ div[data-testid="stMetric"] {
129
+ background: #ffffff;
130
+ border: 1px solid #e2e8f0;
131
+ border-radius: 0.9rem;
132
+ padding: 0.9rem 1rem;
133
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
134
+ }
135
+
136
+ .stButton > button[kind="primary"] {
137
+ background: linear-gradient(135deg, #6366f1, #8b5cf6);
138
+ border: none;
139
+ font-weight: 600;
140
+ }
141
+
142
+ footer { visibility: hidden; }
143
+ </style>
144
+ """,
145
+ unsafe_allow_html=True,
146
+ )
147
+
148
+
149
+ def hero(title: str, subtitle: str) -> None:
150
+ """Render the gradient hero banner."""
151
+ st.markdown(
152
+ f"""
153
+ <div class="absa-hero">
154
+ <h1>{title}</h1>
155
+ <p>{subtitle}</p>
156
+ </div>
157
+ """,
158
+ unsafe_allow_html=True,
159
+ )
160
+
161
+
162
+ def feature_card(icon: str, title: str, description: str, accent: str = "#6366f1") -> None:
163
+ """Render a clickable-style feature card."""
164
+ st.markdown(
165
+ f"""
166
+ <div class="absa-card">
167
+ <div class="card-icon">{icon}</div>
168
+ <div class="card-title">{title}</div>
169
+ <div class="card-desc">{description}</div>
170
+ </div>
171
+ """,
172
+ unsafe_allow_html=True,
173
+ )
174
+
175
+
176
+ def sentiment_badge(sentiment: str) -> str:
177
+ """HTML for a colored sentiment badge."""
178
+ color = SENTIMENT_COLORS.get(sentiment, "#64748b")
179
+ return f'<span class="absa-badge" style="background:{color};">{sentiment}</span>'
180
+
181
+
182
+ def aspect_card(aspect: str, sentiment: str, confidence: float) -> str:
183
+ """HTML for a single aspect result card with a confidence bar."""
184
+ color = SENTIMENT_COLORS.get(sentiment, "#64748b")
185
+ pct = max(0.0, min(100.0, float(confidence) * 100.0))
186
+ return (
187
+ f'<div class="absa-result" style="border-left-color:{color};">'
188
+ f'<div class="aspect-name">{aspect} &nbsp; {sentiment_badge(sentiment)}</div>'
189
+ f'<div class="confidence">Confidence: {confidence:.2f}</div>'
190
+ f'<div class="absa-bar"><div class="absa-bar-fill" style="width:{pct:.1f}%;background:{color};"></div></div>'
191
+ f"</div>"
192
+ )
193
+
194
+
195
+ def render_aspects(aspects: Iterable[dict]) -> None:
196
+ """Render a list of aspect dicts as styled cards."""
197
+ items = list(aspects)
198
+ if not items:
199
+ st.info("No aspects detected in this text.")
200
+ return
201
+ html = "".join(
202
+ aspect_card(
203
+ str(a.get("aspect", "N/A")),
204
+ str(a.get("sentiment", "neutral")),
205
+ float(a.get("confidence", 0.0)),
206
+ )
207
+ for a in items
208
+ )
209
+ st.markdown(html, unsafe_allow_html=True)
210
+
211
+
212
+ def language_options() -> list[str]:
213
+ return ["auto", "en", "hi", "hinglish"]
214
+
215
+
216
+ def sample_reviews() -> list[dict[str, str]]:
217
+ return _SAMPLE_REVIEWS
218
+
219
+
220
+ def require_admin() -> bool:
221
+ """Gate admin pages behind an optional password (env `ADMIN_PASSWORD`).
222
+
223
+ When `ADMIN_PASSWORD` is empty the admin section is open; otherwise a
224
+ lock screen is shown until the correct password is entered. Callers should
225
+ `st.stop()` when this returns ``False``.
226
+ """
227
+ password = os.getenv("ADMIN_PASSWORD", "")
228
+ if not password:
229
+ return True
230
+ if st.session_state.get("admin_ok"):
231
+ return True
232
+
233
+ st.markdown(
234
+ """
235
+ <div style="max-width:28rem;margin:4rem auto;text-align:center;">
236
+ <div style="font-size:2.5rem;">🔒</div>
237
+ <h2>Admin access</h2>
238
+ <p style="color:#64748b;">This section is restricted. Enter the admin
239
+ password to view application status and features.</p>
240
+ </div>
241
+ """,
242
+ unsafe_allow_html=True,
243
+ )
244
+ candidate = st.text_input("Admin password", type="password", key="admin_password")
245
+ if st.button("Unlock", type="primary", key="admin_unlock"):
246
+ if candidate == password:
247
+ st.session_state["admin_ok"] = True
248
+ st.rerun()
249
+ else:
250
+ st.error("Incorrect password.")
251
+ return False
frontend/views/admin/batch.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin — batch analytics: CSV upload, live job progress, result download."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import io
6
+ import time
7
+
8
+ import pandas as pd
9
+ import streamlit as st
10
+ from absa_client import get_client
11
+ from ui import apply_theme, hero, require_admin
12
+
13
+ apply_theme()
14
+
15
+ if not require_admin():
16
+ st.stop()
17
+
18
+ client = get_client()
19
+
20
+ hero(
21
+ "Admin · Batch Analytics 📁",
22
+ "Upload a CSV of product reviews, queue an async batch job on the worker, "
23
+ "track progress live, and download the full annotated results.",
24
+ )
25
+
26
+ col_up, col_hint = st.columns([2, 1], gap="large")
27
+
28
+ with col_up:
29
+ uploaded = st.file_uploader("Upload a CSV file", type=["csv"], accept_multiple_files=False)
30
+
31
+ with col_hint:
32
+ st.markdown(
33
+ """
34
+ <div class="absa-card">
35
+ <div class="card-title">Required format</div>
36
+ <div class="card-desc">A CSV with a <code>text</code> column. Limits:</div>
37
+ <ul>
38
+ <li>Max 10,000 rows</li>
39
+ <li>Max 50 MB</li>
40
+ </ul>
41
+ </div>
42
+ """,
43
+ unsafe_allow_html=True,
44
+ )
45
+
46
+ df_preview = None
47
+ if uploaded is not None:
48
+ try:
49
+ df_preview = pd.read_csv(io.BytesIO(uploaded.getvalue()))
50
+ st.success(f"Loaded {len(df_preview)} rows with columns: {', '.join(df_preview.columns)}")
51
+ if "text" not in df_preview.columns:
52
+ st.error("CSV must contain a **text** column.")
53
+ df_preview = None
54
+ else:
55
+ st.dataframe(df_preview.head(5), use_container_width=True, hide_index=True)
56
+ except Exception as exc:
57
+ st.error(f"Could not parse CSV: {exc}")
58
+
59
+ if df_preview is not None:
60
+ start = st.button("🚀 Start Batch Processing", type="primary", use_container_width=True)
61
+
62
+ if start:
63
+ with st.spinner("Uploading and queuing job…"):
64
+ job = client.upload_batch(uploaded)
65
+ if job:
66
+ st.session_state["batch_job_id"] = job.get("job_id")
67
+ st.rerun()
68
+
69
+ job_id = st.session_state.get("batch_job_id")
70
+ if job_id:
71
+ st.markdown("---")
72
+ st.subheader(f"Job progress — `{job_id[:8]}…`")
73
+
74
+ progress = st.progress(0.0)
75
+ status = st.status("Queued…", expanded=True)
76
+
77
+ while True:
78
+ job = client.get_batch_status(job_id)
79
+ if job is None:
80
+ st.error("Failed to fetch job status.")
81
+ break
82
+
83
+ total = max(int(job.get("total_reviews") or 0), 1)
84
+ processed = int(job.get("processed") or 0)
85
+ ratio = min(processed / total, 1.0)
86
+ progress.progress(ratio)
87
+
88
+ label = {
89
+ "queued": "Queued — waiting for a worker…",
90
+ "processing": f"Processing {processed}/{total} reviews…",
91
+ "completed": f"Completed — {processed}/{total} reviews analyzed ✅",
92
+ "failed": "Job failed ❌",
93
+ }.get(job.get("status"), job.get("status", "…"))
94
+ status.update(
95
+ label=label,
96
+ state="running" if job.get("status") in ("queued", "processing") else "complete",
97
+ )
98
+
99
+ if job.get("status") in ("completed", "failed"):
100
+ break
101
+ time.sleep(2)
102
+
103
+ c1, c2, c3 = st.columns(3)
104
+ c1.metric("Total Reviews", job.get("total_reviews"))
105
+ c2.metric("Processed", job.get("processed"))
106
+ c3.metric("Status", job.get("status"))
107
+
108
+ if job.get("status") == "completed":
109
+ result_bytes = client.download_result(job_id)
110
+ if result_bytes is not None:
111
+ st.download_button(
112
+ "⬇️ Download results (CSV)",
113
+ data=result_bytes,
114
+ file_name=f"absa_results_{job_id}.csv",
115
+ mime="text/csv",
116
+ type="primary",
117
+ use_container_width=True,
118
+ )
frontend/views/admin/monitor.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin — system monitor: health checks, service metadata, diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import streamlit as st
6
+ from absa_client import get_client
7
+ from ui import apply_theme, hero, require_admin
8
+
9
+ apply_theme()
10
+
11
+ if not require_admin():
12
+ st.stop()
13
+
14
+ client = get_client()
15
+
16
+ hero(
17
+ "Admin · System Monitor 🩺",
18
+ "Live health checks, model metadata, and API configuration for the ABSA service.",
19
+ )
20
+
21
+ auto_refresh = st.toggle("Auto-refresh every 5s", value=False)
22
+
23
+ col_h, col_i = st.columns(2)
24
+ with col_h:
25
+ health = client.get_health()
26
+ if health:
27
+ st.success("### API is healthy ✅")
28
+ for key, value in health.items():
29
+ st.markdown(f"- **{key}**: `{value}`")
30
+ else:
31
+ st.error("### API unreachable ❌")
32
+
33
+ with col_i:
34
+ info = client.get_info()
35
+ if info:
36
+ st.info("### Service info")
37
+ for key, value in info.items():
38
+ st.markdown(f"- **{key}**: `{value}`")
39
+
40
+ st.markdown("---")
41
+
42
+ col_a, col_b, col_c = st.columns(3)
43
+ col_a.metric("Endpoint", client.base_url)
44
+ col_b.metric("Timeout (s)", "60")
45
+ col_c.metric("Languages", "en · hi · hinglish")
46
+
47
+ st.caption(
48
+ "Tip: run the API with `uvicorn api.main:app --port 8000` and this "
49
+ "dashboard with `streamlit run streamlit_app/Home.py`."
50
+ )
51
+
52
+ if auto_refresh:
53
+ st.rerun()
frontend/views/admin/overview.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Admin — overview: application status and feature details."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import streamlit as st
6
+ from absa_client import get_client
7
+ from ui import apply_theme, feature_card, hero, require_admin
8
+
9
+ apply_theme()
10
+
11
+ if not require_admin():
12
+ st.stop()
13
+
14
+ client = get_client()
15
+
16
+ hero(
17
+ "Admin · Application Overview 📊",
18
+ "Status of the ABSA platform, model metadata, and everything available in this deployment.",
19
+ )
20
+
21
+ # ── Live status strip ─────────────────────────────────────────────────────────
22
+ info = client.get_info()
23
+ health = client.get_health()
24
+ api_online = health is not None
25
+
26
+ col1, col2, col3, col4 = st.columns(4)
27
+ col1.metric(
28
+ "API Status",
29
+ "Online" if api_online else "Offline",
30
+ delta="●" if api_online else "○",
31
+ delta_color="normal" if api_online else "off",
32
+ )
33
+ col2.metric(
34
+ "Database",
35
+ "connected" if api_online else "unknown",
36
+ )
37
+ col3.metric("Model", (info or {}).get("model_name", "xlm-roberta-base-absa"))
38
+ col4.metric("API Base URL", client.base_url)
39
+
40
+ st.markdown("---")
41
+
42
+ # ── Detailed status ───────────────────────────────────────────────────────────
43
+ col_h, col_i = st.columns(2, gap="large")
44
+
45
+ with col_h:
46
+ st.subheader("Health check")
47
+ if health:
48
+ for key, value in health.items():
49
+ st.markdown(f"- **{key}**: `{value}`")
50
+ else:
51
+ st.error("API unreachable — start `uvicorn api.main:app --port 8000`.")
52
+
53
+ with col_i:
54
+ st.subheader("Service info")
55
+ if info:
56
+ for key, value in info.items():
57
+ st.markdown(f"- **{key}**: `{value}`")
58
+ else:
59
+ st.warning("No service info available.")
60
+
61
+ st.markdown("---")
62
+
63
+ # ── Feature details ───────────────────────────────────────────────────────────
64
+ st.subheader("What's available")
65
+ col_a, col_b, col_c = st.columns(3)
66
+ with col_a:
67
+ feature_card(
68
+ "💬",
69
+ "Sentiment Analyzer",
70
+ "The public view — users paste a review and get aspect-level sentiment.",
71
+ )
72
+ with col_b:
73
+ feature_card(
74
+ "📁",
75
+ "Batch Analytics",
76
+ "Upload a CSV of reviews, run async jobs, and download annotated results.",
77
+ )
78
+ with col_c:
79
+ feature_card(
80
+ "🩺",
81
+ "System Monitor",
82
+ "Health checks, service metadata, and auto-refresh diagnostics.",
83
+ )
84
+
85
+ st.markdown(
86
+ """
87
+ <div class="absa-card">
88
+ <div class="card-title">REST API</div>
89
+ <div class="card-desc">
90
+ All dashboard features are backed by the JSON API at <code>/docs</code>:
91
+ </div>
92
+ <code>POST /predict</code> · <code>POST /batch</code> ·
93
+ <code>GET /status/{job_id}</code> · <code>GET /health</code> ·
94
+ <code>GET /info</code> · <code>GET /metrics</code>
95
+ </div>
96
+ """,
97
+ unsafe_allow_html=True,
98
+ )
frontend/views/predict.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """User-facing view — the only screen regular users see.
2
+
3
+ Type/paste a review, pick a language, get aspect-level sentiment back.
4
+ No dashboards, no metrics, no administration here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import streamlit as st
10
+ from absa_client import get_client
11
+ from ui import LANGUAGE_LABELS, apply_theme, language_options, render_aspects
12
+
13
+ client = get_client()
14
+ apply_theme()
15
+
16
+ st.markdown(
17
+ """
18
+ <div style="text-align:center;padding:1.2rem 0 0.5rem 0;">
19
+ <h1 style="font-size:2.3rem;margin:0;letter-spacing:-0.02em;">💬 Sentiment Analyzer</h1>
20
+ <p style="color:#64748b;font-size:1.05rem;margin-top:0.4rem;">
21
+ Paste a review or comment in English, Hindi, or Hinglish and get
22
+ aspect-level sentiment instantly.
23
+ </p>
24
+ </div>
25
+ """,
26
+ unsafe_allow_html=True,
27
+ )
28
+
29
+ # ── Input ─────────────────────────────────────────────────────────────────────
30
+ text = st.text_area(
31
+ "Your comment",
32
+ height=170,
33
+ placeholder="e.g. The camera is amazing but the battery drains too fast.",
34
+ key="review_text",
35
+ )
36
+
37
+ col_lang, col_btn = st.columns([1, 2], gap="medium")
38
+ with col_lang:
39
+ language = st.selectbox(
40
+ "Language",
41
+ language_options(),
42
+ index=0,
43
+ format_func=lambda code: LANGUAGE_LABELS[code],
44
+ )
45
+ with col_btn:
46
+ st.caption("Auto-detects the language if unsure.")
47
+ analyze = st.button(
48
+ "Analyze Sentiment",
49
+ type="primary",
50
+ use_container_width=True,
51
+ disabled=not text.strip(),
52
+ )
53
+
54
+ st.markdown("---")
55
+
56
+ # ── Results ───────────────────────────────────────────────────────────────────
57
+ if analyze and text.strip():
58
+ with st.spinner("Analyzing…"):
59
+ result = client.predict(text.strip(), language)
60
+
61
+ if result:
62
+ aspects = result.get("aspects") or []
63
+
64
+ st.subheader("Aspects found")
65
+ render_aspects(aspects)
66
+ else:
67
+ st.info("Enter a review above and press **Analyze Sentiment** to get started.")
{ml/notebooks → notebooks}/01_data_exploration.ipynb RENAMED
File without changes
{ml/notebooks → notebooks}/03_model_comparison.ipynb RENAMED
File without changes
{ml/notebooks → notebooks}/03_train_colab.ipynb RENAMED
File without changes
{ml/notebooks → notebooks}/04_qlora_colab.ipynb RENAMED
File without changes
{ml/notebooks → notebooks}/08_final_evaluation.ipynb RENAMED
File without changes
pyproject.toml CHANGED
@@ -1,10 +1,10 @@
1
  [build-system]
2
  requires = ["setuptools>=68.0"]
3
- build-backend = "setuptools.backends._legacy:_Backend"
4
 
5
  [project]
6
  name = "multilingual-absa"
7
- version = "2.0.0"
8
  description = "Multilingual Aspect-Based Sentiment Analysis for English, Hindi, and Hinglish"
9
  readme = "README.md"
10
  license = {text = "MIT"}
@@ -20,12 +20,67 @@ classifiers = [
20
  "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
  "Topic :: Text Processing :: Linguistic",
22
  ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
 
24
  [tool.pytest.ini_options]
25
  testpaths = ["tests"]
26
  python_files = ["test_*.py"]
27
  asyncio_mode = "auto"
28
  asyncio_default_fixture_loop_scope = "function"
 
29
 
30
  [tool.ruff]
31
  target-version = "py310"
@@ -33,7 +88,31 @@ line-length = 120
33
 
34
  [tool.ruff.lint]
35
  select = ["E", "F", "I", "N", "W"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  [tool.coverage.run]
38
- source = ["app", "absa"]
39
  omit = ["*/tests/*", "*/__pycache__/*"]
 
 
 
 
 
 
1
  [build-system]
2
  requires = ["setuptools>=68.0"]
3
+ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "multilingual-absa"
7
+ version = "2.1.0"
8
  description = "Multilingual Aspect-Based Sentiment Analysis for English, Hindi, and Hinglish"
9
  readme = "README.md"
10
  license = {text = "MIT"}
 
20
  "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
  "Topic :: Text Processing :: Linguistic",
22
  ]
23
+ dependencies = [
24
+ # ML / NLP
25
+ "transformers>=4.41.0",
26
+ "datasets>=2.19.0",
27
+ "torch>=2.3.0",
28
+ "onnxruntime>=1.18.0",
29
+ "optimum[onnxruntime]>=1.19.0",
30
+ "peft>=0.10.0",
31
+ "fasttext-predict>=0.9.2.4",
32
+ "indic-nlp-library @ git+https://github.com/anoopkunchukuttan/indic_nlp_library.git",
33
+ "nlpaug>=1.1.11",
34
+ # Data & Metrics
35
+ "scikit-learn>=1.4.2",
36
+ "pandas>=2.2.3",
37
+ "numpy>=1.26.4",
38
+ "seqeval>=1.2.2",
39
+ # Experiment tracking & pipelines
40
+ "mlflow>=2.15.0",
41
+ "dvc>=3.51.1",
42
+ "evidently>=0.4.30",
43
+ # API
44
+ "fastapi>=0.115.0",
45
+ "uvicorn>=0.29.0",
46
+ "python-multipart>=0.0.9",
47
+ "slowapi>=0.1.9",
48
+ "pydantic>=2.7.1",
49
+ "python-dotenv>=1.0.1",
50
+ "prometheus-fastapi-instrumentator>=7.0.0",
51
+ # Async workers
52
+ "celery>=5.4.0",
53
+ "redis>=5.0.4",
54
+ # Database
55
+ "psycopg2-binary>=2.9.9",
56
+ # Frontend (Streamlit dashboard — calls the FastAPI backend over HTTP)
57
+ "streamlit>=1.37.0",
58
+ "httpx>=0.28.0",
59
+ ]
60
+
61
+ [project.optional-dependencies]
62
+ dev = [
63
+ "pytest>=8.2.0",
64
+ "pytest-asyncio>=0.23.7",
65
+ "pytest-cov>=5.0.0",
66
+ "coverage>=7.5.0",
67
+ "ruff>=0.6.0",
68
+ "mypy>=1.10.0",
69
+ "bandit>=1.7.9",
70
+ "radon>=6.0.1",
71
+ "scalene>=1.5.30",
72
+ ]
73
+
74
+ [tool.setuptools.packages.find]
75
+ where = ["src", "."]
76
+ include = ["absa*", "api*"]
77
 
78
  [tool.pytest.ini_options]
79
  testpaths = ["tests"]
80
  python_files = ["test_*.py"]
81
  asyncio_mode = "auto"
82
  asyncio_default_fixture_loop_scope = "function"
83
+ addopts = "--cov=api --cov=absa --cov-report=term-missing --cov-report=xml"
84
 
85
  [tool.ruff]
86
  target-version = "py310"
 
88
 
89
  [tool.ruff.lint]
90
  select = ["E", "F", "I", "N", "W"]
91
+ extend-ignore = ["N999"] # module file name style
92
+
93
+ [tool.mypy]
94
+ python_version = "3.10"
95
+ warn_return_any = true
96
+ warn_unused_configs = true
97
+ ignore_missing_imports = true
98
+ check_untyped_defs = true
99
+ exclude = [
100
+ "tests/",
101
+ "scripts/",
102
+ "notebooks/",
103
+ "docs/",
104
+ ]
105
+
106
+ [tool.bandit]
107
+ exclude_dirs = ["tests", "scripts"]
108
+ targets = ["api", "absa"]
109
+ skips = ["B101"] # allow assert (pytest usage)
110
 
111
  [tool.coverage.run]
112
+ source = ["api", "absa"]
113
  omit = ["*/tests/*", "*/__pycache__/*"]
114
+
115
+ [tool.coverage.report]
116
+ show_missing = true
117
+ skip_covered = true
118
+ fail_under = 24