Spaces:
Runtime error
Restructure to api/src and add Streamlit UI
Browse filesRefactors 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.
- Makefile +46 -0
- {absa → api}/__init__.py +0 -0
- {absa/data → api/core}/__init__.py +0 -0
- {app → api}/main.py +11 -28
- {absa/evaluation → api/middleware}/__init__.py +0 -0
- {app → api}/middleware/dependencies.py +0 -0
- {app → api}/middleware/metrics.py +0 -0
- {absa/models → api/routes}/__init__.py +0 -0
- {app → api}/routes/predict.py +5 -5
- {app → api}/routes/results.py +0 -0
- {absa/training → api/schemas}/__init__.py +0 -0
- {app → api}/schemas/db_models.py +0 -0
- {app → api}/schemas/schemas.py +0 -0
- {absa/utils → api/services}/__init__.py +0 -0
- {app → api}/services/absa_pipeline.py +220 -29
- {app → api}/services/lang_service.py +0 -0
- {app → api}/tasks/__init__.py +0 -0
- {app → api}/tasks/batch_tasks.py +4 -4
- app/core/templates.py +0 -36
- app/middleware/csrf.py +0 -59
- app/routes/pages.py +0 -327
- app/static/css/app.css +0 -497
- app/templates/base.html +0 -449
- app/templates/macros/ui.html +0 -178
- app/templates/pages/batch.html +0 -124
- app/templates/pages/monitor.html +0 -161
- app/templates/pages/predict.html +0 -107
- app/templates/partials/batch_charts.html +0 -114
- app/templates/partials/batch_progress.html +0 -51
- app/templates/partials/monitor_health.html +0 -19
- app/templates/partials/predict_result.html +0 -101
- docker/Dockerfile +9 -5
- docker/Dockerfile.prod +8 -6
- docker/docker-compose.yml +1 -1
- docs/HTMX_MIGRATION.md +0 -92
- dvc.yaml +10 -10
- frontend/Home.py +57 -0
- {app → frontend}/__init__.py +0 -0
- frontend/absa_client.py +84 -0
- frontend/ui.py +251 -0
- frontend/views/admin/batch.py +118 -0
- frontend/views/admin/monitor.py +53 -0
- frontend/views/admin/overview.py +98 -0
- frontend/views/predict.py +67 -0
- {ml/notebooks → notebooks}/01_data_exploration.ipynb +0 -0
- {ml/notebooks → notebooks}/03_model_comparison.ipynb +0 -0
- {ml/notebooks → notebooks}/03_train_colab.ipynb +0 -0
- {ml/notebooks → notebooks}/04_qlora_colab.ipynb +0 -0
- {ml/notebooks → notebooks}/08_final_evaluation.ipynb +0 -0
- pyproject.toml +82 -3
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
@@ -1,23 +1,20 @@
|
|
| 1 |
-
|
| 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
|
| 16 |
-
from
|
| 17 |
-
from
|
| 18 |
-
from
|
| 19 |
-
from
|
| 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:
|
| 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")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -7,11 +7,11 @@ import tempfile
|
|
| 7 |
import time
|
| 8 |
import re
|
| 9 |
|
| 10 |
-
from
|
| 11 |
-
from
|
| 12 |
-
from
|
| 13 |
-
from
|
| 14 |
-
from
|
| 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 |
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -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
|
| 22 |
-
from
|
| 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 =
|
| 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 |
-
|
| 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 |
-
|
| 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 |
-
|
| 465 |
-
|
| 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,
|
|
|
|
|
|
|
| 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 |
-
|
| 523 |
found_aspects = self._extract_aspects(text_lower)
|
| 524 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
results = []
|
| 526 |
for aspect_label, start_char, end_char in found_aspects:
|
| 527 |
-
#
|
|
|
|
|
|
|
| 528 |
aspect_lower = aspect_label.lower()
|
| 529 |
-
|
| 530 |
-
|
| 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]] = []
|
|
File without changes
|
|
File without changes
|
|
@@ -1,7 +1,7 @@
|
|
| 1 |
-
from
|
| 2 |
-
from
|
| 3 |
-
from
|
| 4 |
-
from
|
| 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
|
|
@@ -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]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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."})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 & 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 %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -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 %}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -2,14 +2,16 @@
|
|
| 2 |
FROM python:3.11-slim AS builder
|
| 3 |
|
| 4 |
WORKDIR /app
|
| 5 |
-
COPY
|
|
|
|
|
|
|
| 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
|
| 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
|
|
|
|
| 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", "
|
|
|
|
| 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"]
|
|
@@ -1,17 +1,19 @@
|
|
| 1 |
FROM python:3.11-slim as builder
|
| 2 |
WORKDIR /app
|
| 3 |
-
COPY
|
| 4 |
-
|
|
|
|
|
|
|
| 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
|
| 11 |
-
COPY
|
| 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", "
|
|
|
|
| 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"]
|
|
@@ -25,7 +25,7 @@ services:
|
|
| 25 |
context: ../
|
| 26 |
dockerfile: Dockerfile
|
| 27 |
container_name: absa-worker
|
| 28 |
-
command: ["celery", "-A", "
|
| 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}
|
|
@@ -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}`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,11 +1,11 @@
|
|
| 1 |
stages:
|
| 2 |
preprocess_semeval:
|
| 3 |
-
cmd: PYTHONPATH=
|
| 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=
|
| 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
|
|
@@ -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()
|
|
File without changes
|
|
@@ -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()
|
|
@@ -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} {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
|
|
@@ -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 |
+
)
|
|
@@ -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()
|
|
@@ -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 |
+
)
|
|
@@ -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.")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
@@ -1,10 +1,10 @@
|
|
| 1 |
[build-system]
|
| 2 |
requires = ["setuptools>=68.0"]
|
| 3 |
-
build-backend = "setuptools.
|
| 4 |
|
| 5 |
[project]
|
| 6 |
name = "multilingual-absa"
|
| 7 |
-
version = "2.
|
| 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 = ["
|
| 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
|