diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..53e70de9acba84482afa13cf82e1cb64dbdd0854 --- /dev/null +++ b/Makefile @@ -0,0 +1,46 @@ +.PHONY: install dev api frontend worker test lint typecheck security coverage docker-up docker-down clean + +# ── Setup ──────────────────────────────────────────────────────────────── +install: + pip install . + +dev: + pip install -e ".[dev]" + +# ── Services ───────────────────────────────────────────────────────────── +api: + uvicorn api.main:app --host 0.0.0.0 --port 8000 --reload + +frontend: + streamlit run frontend/Home.py --server.port 8501 + +worker: + celery -A api.tasks worker --loglevel=info + +# ── Quality gates ──────────────────────────────────────────────────────── +test: + PYTHONPATH=src pytest + +lint: + PYTHONPATH=src ruff check api src/absa tests + +typecheck: + PYTHONPATH=src mypy api src/absa + +security: + bandit -r api src/absa + +coverage: + PYTHONPATH=src pytest --cov=api --cov=absa --cov-report=term-missing + +# ── Docker ─────────────────────────────────────────────────────────────── +docker-up: + docker compose -f docker/docker-compose.yml up -d + +docker-down: + docker compose -f docker/docker-compose.yml down + +# ── Cleanup ────────────────────────────────────────────────────────────── +clean: + find . -name "__pycache__" -type d -exec rm -rf {} + 2>/dev/null || true + rm -rf .pytest_cache .mypy_cache .ruff_cache .coverage coverage.xml htmlcov diff --git a/absa/__init__.py b/api/__init__.py similarity index 100% rename from absa/__init__.py rename to api/__init__.py diff --git a/absa/data/__init__.py b/api/core/__init__.py similarity index 100% rename from absa/data/__init__.py rename to api/core/__init__.py diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000000000000000000000000000000000000..03019d7836fde60b54924f42a2c33f8be0788169 --- /dev/null +++ b/api/main.py @@ -0,0 +1,56 @@ +import os +from contextlib import asynccontextmanager + +from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.util import get_remote_address + +load_dotenv() + +from api.middleware.dependencies import engine # noqa: E402 +from api.middleware.metrics import instrumentator # noqa: E402 +from api.routes import predict, results # noqa: E402 +from api.schemas.db_models import Base # noqa: E402 +from api.services.absa_pipeline import pipeline # noqa: E402 + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + print("Initializing Database tables...") + Base.metadata.create_all(bind=engine) + + print("Loading Models...") + pipeline.load_models() + + yield + # Shutdown + print("Shutting down...") + + +limiter = Limiter(key_func=get_remote_address) + +app = FastAPI( + title="Multilingual ABSA API", + description="Aspect-Based Sentiment Analysis for English and Hindi", + version="1.0.0", + lifespan=lifespan, +) + +app.state.limiter = limiter +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +app.add_middleware( + CORSMiddleware, + allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:8000,http://localhost:8501").split(","), + allow_credentials=True, + allow_methods=["GET", "POST"], + allow_headers=["*"], +) +app.include_router(predict.router, tags=["Predict"]) +app.include_router(results.router, tags=["System"]) + +instrumentator.instrument(app).expose(app, endpoint="/metrics") diff --git a/absa/evaluation/__init__.py b/api/middleware/__init__.py similarity index 100% rename from absa/evaluation/__init__.py rename to api/middleware/__init__.py diff --git a/app/middleware/dependencies.py b/api/middleware/dependencies.py similarity index 100% rename from app/middleware/dependencies.py rename to api/middleware/dependencies.py diff --git a/app/middleware/metrics.py b/api/middleware/metrics.py similarity index 100% rename from app/middleware/metrics.py rename to api/middleware/metrics.py diff --git a/absa/models/__init__.py b/api/routes/__init__.py similarity index 100% rename from absa/models/__init__.py rename to api/routes/__init__.py diff --git a/app/routes/predict.py b/api/routes/predict.py similarity index 94% rename from app/routes/predict.py rename to api/routes/predict.py index 23141061df2de678104eee7855fc909ba5d284d1..4af0ded561585f1f82cefd0f65502e414e5c8240 100644 --- a/app/routes/predict.py +++ b/api/routes/predict.py @@ -7,11 +7,11 @@ import tempfile import time import re -from app.schemas.schemas import ReviewInput, PredictionResponse, BatchJobResponse -from app.schemas.db_models import Review, AspectResult, BatchJob -from app.middleware.dependencies import get_db -from app.services.absa_pipeline import pipeline -from app.tasks.batch_tasks import process_batch +from api.schemas.schemas import ReviewInput, PredictionResponse, BatchJobResponse +from api.schemas.db_models import Review, AspectResult, BatchJob +from api.middleware.dependencies import get_db +from api.services.absa_pipeline import pipeline +from api.tasks.batch_tasks import process_batch router = APIRouter() diff --git a/app/routes/results.py b/api/routes/results.py similarity index 100% rename from app/routes/results.py rename to api/routes/results.py diff --git a/absa/training/__init__.py b/api/schemas/__init__.py similarity index 100% rename from absa/training/__init__.py rename to api/schemas/__init__.py diff --git a/app/schemas/db_models.py b/api/schemas/db_models.py similarity index 100% rename from app/schemas/db_models.py rename to api/schemas/db_models.py diff --git a/app/schemas/schemas.py b/api/schemas/schemas.py similarity index 100% rename from app/schemas/schemas.py rename to api/schemas/schemas.py diff --git a/absa/utils/__init__.py b/api/services/__init__.py similarity index 100% rename from absa/utils/__init__.py rename to api/services/__init__.py diff --git a/app/services/absa_pipeline.py b/api/services/absa_pipeline.py similarity index 69% rename from app/services/absa_pipeline.py rename to api/services/absa_pipeline.py index 769ee531a72ab2b5c67a578645502b9acca166d0..2d0f69b63e82efb3deac6ac9e0824559950a84f1 100644 --- a/app/services/absa_pipeline.py +++ b/api/services/absa_pipeline.py @@ -12,20 +12,21 @@ Strategy: import os import re -import time import threading +import time from pathlib import Path -from typing import List, Tuple +from typing import List, Optional, Tuple + import numpy as np -from app.schemas.schemas import PredictionResponse, AspectSentiment -from app.services.lang_service import lang_service +from api.schemas.schemas import AspectSentiment, PredictionResponse +from api.services.lang_service import lang_service # ── Optional heavy imports (ONNX custom models) ─────────────────────────────── try: from optimum.onnxruntime import ( - ORTModelForTokenClassification, ORTModelForSequenceClassification, + ORTModelForTokenClassification, ) from transformers import AutoTokenizer @@ -143,6 +144,114 @@ ASPECT_PHRASES: List[str] = sorted( reverse=True, ) +# ── Fallback aspect nouns ───────────────────────────────────────────────────── +# Used when the fixed lexicon misses a comment (e.g. food/service/experience). +# These are the nouns we anchor near sentiment words when no lexicon aspect +# matches. English + transliterated + Devanagari. +GENERAL_ASPECT_NOUNS: set[str] = { + # Experience / service + "food", + "taste", + "flavor", + "flavour", + "service", + "staff", + "experience", + "product", + "quality", + "delivery", + "packaging", + "price", + "cost", + "value", + "design", + "look", + "feel", + "build", + "material", + "comfort", + "durability", + "cleanliness", + "hygiene", + "room", + "location", + "ambience", + "menu", + "portion", + "hotel", + "restaurant", + "support", + "response", + "warranty", + "atmosphere", + "purchase", + "order", + "buy", + # Devices / electronics + "phone", + "smartphone", + "laptop", + "tablet", + "headphones", + "earphones", + "earbuds", + "watch", + "device", + "camera", + "screen", + "display", + "battery", + "speaker", + "speakers", + "keyboard", + "mouse", + "monitor", + "processor", + "performance", + "speed", + "sound", + "audio", + "picture", + "photo", + "video", + "signal", + "network", + "call", + "app", + "software", + "interface", + "ui", + "features", + "battery life", + "charging", + "processor", + "ram", + "memory", + # Hindi transliterated + "khana", + "swad", + "delivery", + "experience", + "speed", + "sound", + "signal", + # Devanagari + "खाना", + "सेवा", + "गुणवत्ता", + "कीमत", + "डिज़ाइन", + "उत्पाद", + "अनुभव", + "बैटरी", + "कैमरा", + "प्रदर्शन", + "आवाज़", + "स्पीड", + "डिलीवरी", + "स्वाद", +} + # ── Sentiment lexicon ───────────────────────────────────────────────────────── POSITIVE_WORDS = { "excellent", @@ -182,6 +291,11 @@ POSITIVE_WORDS = { "recommended", "worth", "affordable", + "best", + "better", + "awesome", + "favorite", + "favourite", "value", "effective", "efficient", @@ -215,6 +329,18 @@ POSITIVE_WORDS = { "shandar", "zabardast", "mast", + # Hindi positive (Devanagari) + "अच्छा", + "बढ़िया", + "शानदार", + "ज़बरदस्त", + "मस्त", + "पसंद", + "उत्तम", + "उम्दा", + "सुंदर", + "बेहतरीन", + "अद्भुत", } NEGATIVE_WORDS = { @@ -271,6 +397,10 @@ NEGATIVE_WORDS = { "broken", "defective", "faulty", + "worst", + "worse", + "useless", + "pathetic", "average", "ordinary", "basic", @@ -281,6 +411,14 @@ NEGATIVE_WORDS = { "bura", "ganda", "faltu", + # Hindi negative (Devanagari) + "खराब", + "बेकार", + "बुरा", + "घटिया", + "फालतू", + "निराश", + "सस्ता", } NEGATION_WORDS = { @@ -322,12 +460,34 @@ INTENSIFIERS = { } +# Unicode-aware token pattern. `\w` alone misses Devanagari vowel signs +# (combining marks), which would split "खाना" into stray characters, so the +# Devanagari block is included explicitly. +WORD_RE = re.compile(r"[\w\u0900-\u097f'-]+") + +# Split a review into sentiment clauses on punctuation and conjunctions, so a +# sentence like "food was great but service was terrible" is scored per-clause. +# Includes English + transliterated + Devanagari conjunctions. The Devanagari +# ones need whitespace around them, otherwise "या" matches inside "बढ़िया". +_CLAUSE_SPLIT_RE = re.compile( + r"[.!?;,]|\b(?:but|and|yet|however|although|though|while|whereas|because|" + r"since|so|or|nor|lekin|par|magar|aur|kintu|va|ya)\b|" + r"(?<=\s)(?:लेकिन|और|पर|मगर|किंतु)(?=\s)" +) + + +def _split_clauses(text: str) -> List[str]: + """Split a review into clauses (punctuation + conjunction boundaries).""" + parts = [p for p in _CLAUSE_SPLIT_RE.split(text) if p and p.strip()] + return parts or [text] + + def _score_sentence(sentence: str) -> Tuple[float, float]: """ Return (positive_score, negative_score) for a sentence. Handles negation (3-word window) and intensifiers. """ - words = re.findall(r"\b[\w'-]+\b", sentence.lower()) + words = WORD_RE.findall(sentence.lower()) pos, neg = 0.0, 0.0 i = 0 while i < len(words): @@ -399,10 +559,8 @@ class ABSAPipeline: self.aspect_model = ORTModelForTokenClassification.from_pretrained( hf_repo_id, subfolder="aspect_extraction_int8" ) - self.sentiment_model = ( - ORTModelForSequenceClassification.from_pretrained( - hf_repo_id, subfolder="sentiment_int8" - ) + self.sentiment_model = ORTModelForSequenceClassification.from_pretrained( + hf_repo_id, subfolder="sentiment_int8" ) print("Custom ONNX models loaded.") except Exception as e: @@ -420,14 +578,8 @@ class ABSAPipeline: try: print(f"Loading custom ONNX models from {model_path_base}") self.tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base") - self.aspect_model = ORTModelForTokenClassification.from_pretrained( - str(aspect_path) - ) - self.sentiment_model = ( - ORTModelForSequenceClassification.from_pretrained( - str(sentiment_path) - ) - ) + self.aspect_model = ORTModelForTokenClassification.from_pretrained(str(aspect_path)) + self.sentiment_model = ORTModelForSequenceClassification.from_pretrained(str(sentiment_path)) print("Custom ONNX models loaded.") except Exception as e: print(f"Custom model load skipped: {e}") @@ -437,7 +589,7 @@ class ABSAPipeline: self.is_loaded = True - def predict(self, text: str, requested_lang: str = None) -> PredictionResponse: + def predict(self, text: str, requested_lang: Optional[str] = None) -> PredictionResponse: start = time.time() detected_lang = lang_service.detect_language(text) actual_lang = requested_lang or detected_lang @@ -461,14 +613,17 @@ class ABSAPipeline: # ── Custom ONNX path ────────────────────────────────────────────────────── def _predict_onnx(self, text: str) -> List[AspectSentiment]: - inputs = self.tokenizer( - text, return_tensors="pt", truncation=True, max_length=128 - ) + assert self.aspect_model is not None + assert self.sentiment_model is not None + assert self.tokenizer is not None + inputs = self.tokenizer(text, return_tensors="pt", truncation=True, max_length=128) logits = self.aspect_model(**inputs).logits[0].detach().numpy() preds = np.argmax(logits, axis=1) tokens = self.tokenizer.convert_ids_to_tokens(inputs["input_ids"][0]) - raw, current, start_idx = [], [], -1 + raw: List[Tuple[str, int, int]] = [] + current: List[str] = [] + start_idx = -1 skip = { self.tokenizer.cls_token, self.tokenizer.sep_token, @@ -519,17 +674,26 @@ class ABSAPipeline: def _predict_rule_based(self, text: str) -> List[AspectSentiment]: text_lower = text.lower() - sentences = re.split(r"(?<=[.!?])\s+", text) + clauses = _split_clauses(text) found_aspects = self._extract_aspects(text_lower) + # Tier 2: no lexicon aspect matched — anchor nouns near sentiment words. + if not found_aspects: + found_aspects = self._extract_targets_from_sentiment(text_lower) + + # Tier 3: still nothing — fall back to review-level sentiment on a + # generic aspect so every input produces a useful result. + if not found_aspects: + found_aspects = [("Overall", 0, len(text))] + results = [] for aspect_label, start_char, end_char in found_aspects: - # Find the sentence(s) mentioning this aspect for focused scoring + # Score only the clause(s) mentioning this aspect so that mixed + # sentences ("food was great but service was terrible") don't + # cancel each other into neutral. aspect_lower = aspect_label.lower() - context_sentences = [s for s in sentences if aspect_lower in s.lower()] or [ - text - ] - context = " ".join(context_sentences) + context_clauses = [c for c in clauses if aspect_lower in c.lower()] or [text] + context = " ".join(context_clauses) pos, neg = _score_sentence(context) @@ -550,6 +714,33 @@ class ABSAPipeline: ) return results + def _extract_targets_from_sentiment(self, text_lower: str) -> List[Tuple[str, int, int]]: + """Fallback aspect extraction: nouns found near sentiment words. + + Handles cases like "The food was great" (noun before the sentiment + word) and "great camera" (noun after it), plus Devanagari text. + """ + tokens = [(m.start(), m.end(), m.group()) for m in WORD_RE.finditer(text_lower)] + sent_words = POSITIVE_WORDS | NEGATIVE_WORDS + + targets: List[Tuple[str, int, int]] = [] + seen_ranges: List[Tuple[int, int]] = [] + + for i, (_, _, token) in enumerate(tokens): + if token not in sent_words: + continue + window = tokens[max(0, i - 4) : i] + tokens[i + 1 : i + 5] + for s, e, noun in window: + if noun in GENERAL_ASPECT_NOUNS: + if any(s >= r0 and e <= r1 for r0, r1 in seen_ranges): + continue + targets.append((noun.title(), s, e)) + seen_ranges.append((s, e)) + break + + targets.sort(key=lambda x: x[1]) + return targets + def _extract_aspects(self, text_lower: str) -> List[Tuple[str, int, int]]: """Find aspect keyword matches; return (label, start, end) sorted by position.""" found: List[Tuple[str, int, int]] = [] diff --git a/app/services/lang_service.py b/api/services/lang_service.py similarity index 100% rename from app/services/lang_service.py rename to api/services/lang_service.py diff --git a/app/tasks/__init__.py b/api/tasks/__init__.py similarity index 100% rename from app/tasks/__init__.py rename to api/tasks/__init__.py diff --git a/app/tasks/batch_tasks.py b/api/tasks/batch_tasks.py similarity index 95% rename from app/tasks/batch_tasks.py rename to api/tasks/batch_tasks.py index 7582716e99c37a0f3b3c4e6ef796063d6e4b02a5..ab18c94d974df13226b31ede9957d973a4d1c0eb 100644 --- a/app/tasks/batch_tasks.py +++ b/api/tasks/batch_tasks.py @@ -1,7 +1,7 @@ -from app.tasks import celery_app -from app.services.absa_pipeline import pipeline -from app.middleware.dependencies import SessionLocal -from app.schemas.db_models import BatchJob, AspectResult, Review +from api.tasks import celery_app +from api.services.absa_pipeline import pipeline +from api.middleware.dependencies import SessionLocal +from api.schemas.db_models import BatchJob, AspectResult, Review import pandas as pd import os import csv diff --git a/app/core/templates.py b/app/core/templates.py deleted file mode 100644 index 35a412593be9328ad178c90e9e31dd8cedc21115..0000000000000000000000000000000000000000 --- a/app/core/templates.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Centralized Jinja2Templates instance. - -Kept in api/app/core/ so every page-rendering router imports from one place, -avoiding multiple conflicting Template objects pointing at the same directory. - -WHY THIS FILE EXISTS --------------------- -FastAPI's Jinja2Templates must be initialised with a directory path. -Centralising it here means that when Phase 3-5 routers add fragment endpoints -they import `templates` from here — no duplication, no divergence. -""" -from __future__ import annotations - -from pathlib import Path - -from fastapi import Request -from fastapi.templating import Jinja2Templates - -# Resolve relative to this file: -# api/app/core/templates.py → api/app/templates/ -_TEMPLATE_DIR: Path = Path(__file__).parent.parent / "templates" - -templates = Jinja2Templates(directory=str(_TEMPLATE_DIR)) - - -# ── Global template context processor ───────────────────────────────────────── -# Ensures every template rendered via this instance always has access to -# csrf_token — even partial/fragment templates that don't go through _base_ctx. - -def _csrf_processor(request: Request) -> dict: # type: ignore[no-redef] - from app.middleware.csrf import generate_csrf_token - return {"csrf_token": generate_csrf_token()} - - -templates.context_processors.append(_csrf_processor) # type: ignore[arg-type] diff --git a/app/main.py b/app/main.py deleted file mode 100644 index 02edaa1bb68ab1a754b4721d4a1929589fb8bd2b..0000000000000000000000000000000000000000 --- a/app/main.py +++ /dev/null @@ -1,73 +0,0 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware -from contextlib import asynccontextmanager -from dotenv import load_dotenv -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.util import get_remote_address -from slowapi.errors import RateLimitExceeded - -import os -from fastapi.staticfiles import StaticFiles -from pathlib import Path - -load_dotenv() - -from app.routes import predict, results # noqa: E402 -from app.routes import pages # noqa: E402 Phase 2: Jinja2 page routes -from app.middleware.metrics import instrumentator # noqa: E402 -from app.services.absa_pipeline import pipeline # noqa: E402 -from app.schemas.db_models import Base # noqa: E402 -from app.middleware.dependencies import engine # noqa: E402 - - -@asynccontextmanager -async def lifespan(app: FastAPI): - # Startup - print("Initializing Database tables...") - Base.metadata.create_all(bind=engine) - - print("Loading Models...") - pipeline.load_models() - - yield - # Shutdown - print("Shutting down...") - - -limiter = Limiter(key_func=get_remote_address) - -app = FastAPI( - title="Multilingual ABSA API", - description="Aspect-Based Sentiment Analysis for English and Hindi", - version="1.0.0", - lifespan=lifespan, -) - -app.state.limiter = limiter -app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) - -from app.middleware.csrf import CSRFMiddleware # noqa: E402 - -app.add_middleware( - CORSMiddleware, - allow_origins=os.getenv("CORS_ORIGINS", "http://localhost:3000,http://localhost:8000").split(","), - allow_credentials=True, - allow_methods=["GET", "POST"], - allow_headers=["*"], -) -app.add_middleware(CSRFMiddleware) # Skips /api/* routes; protects HTMX form endpoints -app.include_router(predict.router, tags=["Predict"]) -app.include_router(results.router, tags=["System"]) - -instrumentator.instrument(app).expose(app, endpoint="/metrics") - -# Jinja2 / HTMX frontend routing and static files -# Note: StaticFiles is mounted after instrumentator so prometheus -# ignores it for /metrics, although this might still log /static requests. - -app.include_router(pages.router) # include_in_schema=False is set on the router itself - -# Resolve path relative to this file so it works regardless of CWD. -_STATIC_DIR = Path(__file__).parent / "static" -_STATIC_DIR.mkdir(parents=True, exist_ok=True) # idempotent safety guard -app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py deleted file mode 100644 index bcd3066a6193ffdcd2471b342d8e05532a139b19..0000000000000000000000000000000000000000 --- a/app/middleware/csrf.py +++ /dev/null @@ -1,59 +0,0 @@ -import os -import re -from typing import Optional -from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired -from starlette.middleware.base import BaseHTTPMiddleware -from starlette.requests import Request -from starlette.responses import Response - -_CSRF_SECRET = os.getenv("CSRF_SECRET", "unsafe-default-change-in-production") -_CSRF_SALT = "csrf-token" -_SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"} -_EXEMPT_PATHS = {"/metrics", "/health", "/info", "/docs", "/openapi.json"} - -_serializer = URLSafeTimedSerializer(_CSRF_SECRET, salt=_CSRF_SALT) - - -def generate_csrf_token() -> str: - return _serializer.dumps("csrf") - - -def validate_csrf_token(token: str, max_age: int = 3600) -> bool: - try: - _serializer.loads(token, max_age=max_age) - return True - except (BadSignature, SignatureExpired): - return False - - -class CSRFMiddleware(BaseHTTPMiddleware): - async def dispatch(self, request: Request, call_next): - path = request.url.path - needs_csrf = request.method in {"POST"} and path.endswith("/fragment") - is_html_page = request.method in _SAFE_METHODS and not path.startswith("/api/") and not path.startswith("/static/") and path not in _EXEMPT_PATHS - - if needs_csrf: - csrf_cookie = request.cookies.get("csrf_token", "") - csrf_header = request.headers.get("X-CSRF-Token", "") - token = csrf_header or csrf_cookie - - if token and not validate_csrf_token(str(token)): - from fastapi.responses import HTMLResponse - return HTMLResponse( - content="

403: CSRF validation failed

Invalid or expired token. Please refresh the page.

", - status_code=403, - ) - - response: Response = await call_next(request) - - if is_html_page: - response.set_cookie( - key="csrf_token", - value=generate_csrf_token(), - max_age=3600, - secure=False, - httponly=True, - samesite="lax", - ) - - return response diff --git a/app/routes/pages.py b/app/routes/pages.py deleted file mode 100644 index 7c0a33cc29706eb7de1056beea707caffe9d3bbe..0000000000000000000000000000000000000000 --- a/app/routes/pages.py +++ /dev/null @@ -1,327 +0,0 @@ -""" -Page routes — Jinja2/HTMX web frontend. - -WHY THIS FILE EXISTS --------------------- -All HTML-serving GET routes live here, completely separate from the JSON REST -routes in routes/predict.py and routes/results.py. This boundary means: - - • REST routes never return HTML accidentally. - • Page routes never appear in the OpenAPI schema (include_in_schema=False). - • Future phases add HTMX fragment endpoints alongside these page routes - without touching any existing API code. - -WHAT THIS FILE DOES (Phase 2) ------------------------------- -Registers four GET routes that render placeholder Jinja2 templates: - GET / → redirect to /predict - GET /predict → pages/predict.html - GET /batch → pages/batch.html - GET /monitor → pages/monitor.html - -No business logic. No inference. No database queries. -The routes exist only to prove the template rendering infrastructure works. - -HTMX fragment endpoints (POST /predict/fragment, GET /batch/progress/{id}, -GET /monitor/health-partial) will be added in Phases 3-5. -""" -from __future__ import annotations - -from fastapi import APIRouter, Request, Depends, Form -from fastapi.responses import HTMLResponse -from sqlalchemy.orm import Session - -from app.core.templates import templates -from app.middleware.csrf import generate_csrf_token -from app.middleware.dependencies import get_db - -# include_in_schema=False keeps these HTML routes out of the OpenAPI / Swagger UI. -router = APIRouter(include_in_schema=False) - -# ── Navigation structure ─────────────────────────────────────────────────────── -# Mirrors the NAV constant in the React Sidebar.jsx so sidebar rendering is -# driven from a single Python list rather than hard-coded in every template. -_NAV_ITEMS: list[dict[str, str]] = [ - {"path": "/predict", "icon": "psychology", "label": "Predictor"}, - {"path": "/batch", "icon": "cloud_upload", "label": "Batch Analytics"}, - {"path": "/monitor", "icon": "monitoring", "label": "System Health"}, -] - - -def _base_ctx(request: Request, page_title: str, **extra: object) -> dict: - """ - Build the Jinja2 template context that base.html expects. - - Every page renderer calls this so the sidebar and header always receive - the nav items and the current path (for active-link highlighting). - Also includes CSRF token for HTMX form submissions. - """ - from app.middleware.csrf import generate_csrf_token - return { - "request": request, # required by Jinja2Templates - "page_title": page_title, - "nav_items": _NAV_ITEMS, - "current_path": request.url.path, - "csrf_token": generate_csrf_token(), - **extra, - } - - -# ── Routes ───────────────────────────────────────────────────────────────────── - -@router.get("/", response_class=HTMLResponse) -async def index(request: Request) -> HTMLResponse: - """Root → serve the Predict page (same behaviour as React's Navigate redirect).""" - return templates.TemplateResponse( - "pages/predict.html", - _base_ctx(request, "Live Predictor"), - ) - - -@router.get("/predict", response_class=HTMLResponse) -async def predict_page(request: Request) -> HTMLResponse: - """ - Jinja2 Live Predictor page. - Phase 2: renders the layout shell with a placeholder content block. - Phase 3: the content block will contain the HTMX predict form + result panel. - """ - return templates.TemplateResponse( - "pages/predict.html", - _base_ctx(request, "Live Predictor"), - ) - - -@router.get("/batch", response_class=HTMLResponse) -async def batch_page(request: Request) -> HTMLResponse: - """ - Jinja2 Batch Analytics page. - Phase 2: placeholder. - Phase 4: file upload form + progress polling. - """ - return templates.TemplateResponse( - "pages/batch.html", - _base_ctx(request, "Batch Analytics"), - ) - - -from app.routes.results import health_check - -@router.get("/monitor", response_class=HTMLResponse) -async def monitor_page(request: Request) -> HTMLResponse: - """ - Jinja2 System Monitor page. - Phase 5: live health status + performance metrics. - """ - try: - health = await health_check() - ctx = _base_ctx(request, "System Monitor", health=health, error=None) - except Exception: - ctx = _base_ctx(request, "System Monitor", health=None, error="Service temporarily unavailable") - - return templates.TemplateResponse("pages/monitor.html", ctx) -# ── SSE Endpoint for batch progress ────────────────────────────────────────── - -import asyncio -import json -from sse_starlette.sse import EventSourceResponse - -@router.get("/api/batch/progress/{job_id}") -async def batch_progress_sse(job_id: str, db: Session = Depends(get_db)): - """ - SSE endpoint for live batch progress updates. - Clients connect via EventSource and receive progress events every 2 seconds. - """ - async def event_generator(): - try: - import re - if not re.match(r'^[a-fA-F0-9\-]{36}$', job_id): - yield {"event": "error", "data": json.dumps({"detail": "Invalid job ID"})} - return - - while True: - job = await get_batch_status(job_id, db) - data = { - "job_id": job.job_id, - "status": job.status, - "total_reviews": job.total_reviews, - "processed": job.processed, - "result_url": job.result_url, - } - yield {"event": "progress", "data": json.dumps(data)} - - if job.status in ("completed", "failed"): - yield {"event": job.status, "data": json.dumps(data)} - break - - await asyncio.sleep(2) - except Exception: - yield {"event": "error", "data": json.dumps({"detail": "Failed to fetch job progress"})} - - return EventSourceResponse(event_generator()) - -# ── Phase 3 HTMX Endpoints ─────────────────────────────────────────────────── - -from app.schemas.schemas import ReviewInput -from app.routes.predict import predict as predict_json - -@router.post("/predict/fragment", response_class=HTMLResponse) -async def predict_fragment( - request: Request, - text: str = Form(...), - language: str = Form("auto"), - db: Session = Depends(get_db) -) -> HTMLResponse: - """ - Phase 3: HTMX partial for the Predict page. - Calls the EXACT SAME prediction logic as the JSON API. - """ - try: - if language == "auto": - language = None - - prediction = await predict_json(ReviewInput(text=text, language=language), db) - return templates.TemplateResponse( - "partials/predict_result.html", - {"request": request, "result": prediction, "error": None} - ) - except Exception: - return templates.TemplateResponse( - "partials/predict_result.html", - {"request": request, "result": None, "error": "Analysis failed. Please try again."} - ) - -# ── Phase 4 HTMX Endpoints ─────────────────────────────────────────────────── - -from fastapi import UploadFile, File -from app.routes.predict import predict_batch, get_batch_status - -@router.post("/batch/fragment", response_class=HTMLResponse) -async def batch_fragment( - request: Request, - file: UploadFile = File(...), - db: Session = Depends(get_db) -) -> HTMLResponse: - """ - Phase 4: HTMX partial for starting a batch job. - """ - try: - response = await predict_batch(file, db) - return templates.TemplateResponse( - "partials/batch_progress.html", - {"request": request, "job": response, "error": None} - ) - except Exception: - return templates.TemplateResponse( - "partials/batch_progress.html", - {"request": request, "job": None, "error": "Batch processing failed. Please try again."} - ) - -@router.get("/batch/progress/{job_id}", response_class=HTMLResponse) -async def batch_progress_fragment( - request: Request, - job_id: str, - db: Session = Depends(get_db) -) -> HTMLResponse: - """ - Phase 4: HTMX partial for polling batch job status. - """ - try: - job = await get_batch_status(job_id, db) - return templates.TemplateResponse( - "partials/batch_progress.html", - {"request": request, "job": job, "error": None} - ) - except Exception: - return templates.TemplateResponse( - "partials/batch_progress.html", - {"request": request, "job": None, "error": "Failed to retrieve job status."} - ) - -# ── Phase 5 HTMX Endpoints ─────────────────────────────────────────────────── - -@router.get("/monitor/health-partial", response_class=HTMLResponse) -async def monitor_health_fragment(request: Request) -> HTMLResponse: - """ - Phase 5: HTMX partial for polling the system health status. - Uses the exact same health logic as the JSON API. - """ - try: - health = await health_check() - return templates.TemplateResponse( - "partials/monitor_health.html", - {"request": request, "health": health, "error": None} - ) - except Exception: - return templates.TemplateResponse( - "partials/monitor_health.html", - {"request": request, "health": None, "error": "Health check failed. Service may be unavailable."} - ) - -import pandas as pd -from pathlib import Path -import json - -@router.get("/batch/charts/{job_id}", response_class=HTMLResponse) -async def batch_charts_fragment(request: Request, job_id: str) -> HTMLResponse: - """ - Phase 5.6: HTMX partial for rendering charts. - Parses the generated CSV and passes JSON directly to the template for Chart.js. - """ - try: - file_path = Path(f"data/results/{job_id}.csv") - if not file_path.exists(): - return templates.TemplateResponse("partials/batch_charts.html", {"request": request, "error": "CSV not found"}) - - df = pd.read_csv(file_path) - - lang_pie = [] - if "language" in df.columns: - counts = df["language"].value_counts().to_dict() - lang_pie = [{"name": str(k), "value": int(v)} for k, v in counts.items()] - - aspect_heat = [] - if "aspect" in df.columns and "sentiment" in df.columns: - # Group by aspect and sentiment - grouped = df.groupby(["aspect", "sentiment"]).size().unstack(fill_value=0) - for aspect, row in grouped.iterrows(): - if pd.isna(aspect) or not aspect: - continue - aspect_heat.append({ - "aspect": str(aspect), - "positive": int(row.get("positive", 0)), - "negative": int(row.get("negative", 0)), - "neutral": int(row.get("neutral", 0)), - "conflict": int(row.get("conflict", 0)) - }) - - sent_line = [] - if "sentiment" in df.columns: - df_sent = df[df["sentiment"].notna()] - n = len(df_sent) - # Create 7 chunks for the line chart - chunk_size = max(1, n // 7) if n > 0 else 1 - for i in range(7): - chunk = df_sent.iloc[i*chunk_size : (i+1)*chunk_size] - if chunk.empty: - break - counts = chunk["sentiment"].value_counts().to_dict() - sent_line.append({ - "name": f"Batch {i+1}", - "positive": int(counts.get("positive", 0)), - "negative": int(counts.get("negative", 0)), - "neutral": int(counts.get("neutral", 0)), - "conflict": int(counts.get("conflict", 0)) - }) - - return templates.TemplateResponse( - "partials/batch_charts.html", - { - "request": request, - "language_pie": json.dumps(lang_pie), - "aspect_heatmap": json.dumps(aspect_heat), - "sentiment_chart": json.dumps(sent_line), - "error": None - } - ) - except Exception: - return templates.TemplateResponse("partials/batch_charts.html", {"request": request, "error": "An unexpected error occurred while generating charts."}) diff --git a/app/static/css/app.css b/app/static/css/app.css deleted file mode 100644 index 2172e8c7d1908ceb84c74a1afa199df148bf1c5d..0000000000000000000000000000000000000000 --- a/app/static/css/app.css +++ /dev/null @@ -1,497 +0,0 @@ -/* - * SentimentAI — Application Design System - * - * WHY THIS FILE EXISTS - * -------------------- - * The React dashboard used Tailwind's @apply directive to define component - * classes (badge-positive, card, btn-primary, etc.) inside index.css. Those - * @apply rules require a compiled Tailwind build step which we are eliminating. - * - * This file replaces index.css + the @apply rules with equivalent plain CSS. - * Tailwind utility classes (bg-*, text-*, flex, etc.) are still available via - * the CDN Play CDN loaded in base.html — this file only contains component-level - * classes that the CDN cannot generate from the HTML scan. - * - * Design token values are taken verbatim from dashboard/tailwind.config.js. - * Do not change token values here without updating the Tailwind CDN config in - * base.html — they must stay in sync. - * - * SECTIONS - * -------- - * 1. Base / Reset - * 2. Material Symbols Outlined icon font - * 3. Scrollbar - * 4. Focus ring - * 5. Layout helpers (glass-panel, sidebar, overlay) - * 6. Navigation (nav-item, nav-item-active) - * 7. Badges (badge-positive, -negative, -neutral, -processing, -error) - * 8. Highlights (highlight-positive, -negative, -neutral) - * 9. Cards (card, card-low, stat-card) - * 10. Form controls (input-base, textarea reset) - * 11. Button (btn-primary) - * 12. Drag-and-drop (drag-active) - * 13. HTMX (htmx-indicator) - * 14. Animations (keyframes + helper classes) - * 15. Toast notices - */ - -/* ── 1. Base / Reset ────────────────────────────────────────────────────────── */ - -html { - color-scheme: dark; - scroll-behavior: smooth; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -body { - background-color: #0b1326; - color: #dae2fd; - font-family: 'Inter', ui-sans-serif, system-ui, sans-serif; - min-height: 100vh; - margin: 0; -} - -*, -*::before, -*::after { - box-sizing: border-box; -} - -/* ── 2. Material Symbols Outlined ───────────────────────────────────────────── */ -/* - * Mirrors the class defined in React's index.css exactly. - * The font itself is loaded via Google Fonts CDN in base.html. - */ -.material-symbols-outlined { - font-family: 'Material Symbols Outlined'; - font-weight: normal; - font-style: normal; - font-size: 20px; - line-height: 1; - letter-spacing: normal; - text-transform: none; - display: inline-block; - white-space: nowrap; - word-wrap: normal; - direction: ltr; - -webkit-font-smoothing: antialiased; - user-select: none; - vertical-align: middle; -} - -/* ── 3. Scrollbar ────────────────────────────────────────────────────────────── */ - -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.12); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.22); } - -/* ── 4. Focus ring ──────────────────────────────────────────────────────────── */ - -:focus-visible { - outline: 2px solid #c0c1ff; - outline-offset: 2px; -} - -/* ── 5. Layout helpers ──────────────────────────────────────────────────────── */ - -.glass-panel { - background-color: rgba(23, 31, 51, 0.75); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - border: 1px solid rgba(255, 255, 255, 0.06); -} - -/* Sidebar slide-in / slide-out on mobile */ -.sidebar { - transform: translateX(-100%); - transition: transform 250ms ease-out; -} -.sidebar.sidebar--open { - transform: translateX(0); -} -@media (min-width: 768px) { - .sidebar { - transform: translateX(0); - } -} - -/* Mobile overlay (backdrop) */ -.sidebar-overlay { - display: none; - position: fixed; - inset: 0; - background-color: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(4px); - -webkit-backdrop-filter: blur(4px); - z-index: 40; -} -.sidebar-overlay.sidebar-overlay--visible { - display: block; -} - -/* ── 6. Navigation ──────────────────────────────────────────────────────────── */ - -.nav-item { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 12px; - border-radius: 8px; - color: #c7c4d7; - font-size: 14px; - line-height: 20px; - font-weight: 500; - text-decoration: none; - cursor: pointer; - transition: color 150ms ease, background-color 150ms ease; -} -.nav-item:hover { - background-color: rgba(255, 255, 255, 0.05); - color: #dae2fd; -} - -.nav-item-active { - display: flex; - align-items: center; - gap: 12px; - padding: 10px 12px; - border-radius: 8px; - color: #c0c1ff; - background-color: rgba(255, 255, 255, 0.07); - border-right: 2px solid #c0c1ff; - font-size: 14px; - line-height: 20px; - font-weight: 600; - text-decoration: none; -} - -/* ── 7. Badges ──────────────────────────────────────────────────────────────── */ -/* - * All badges share the same structural CSS. The colour variant is applied - * via the class suffix. Each badge is intentionally uppercase + monospace - * to match the React component styling. - */ - -.badge-base { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 8px; - border-radius: 9999px; - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 11px; - line-height: 16px; - font-weight: 500; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.badge-positive { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 8px; - border-radius: 9999px; - background-color: rgba(78, 222, 163, 0.10); - color: #4edea3; - border: 1px solid rgba(78, 222, 163, 0.25); - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 11px; - line-height: 16px; - font-weight: 500; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.badge-negative { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 8px; - border-radius: 9999px; - background-color: rgba(255, 180, 171, 0.10); - color: #ffb4ab; - border: 1px solid rgba(255, 180, 171, 0.25); - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 11px; - line-height: 16px; - font-weight: 500; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.badge-neutral { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 8px; - border-radius: 9999px; - background-color: rgba(144, 143, 160, 0.10); - color: #c7c4d7; - border: 1px solid rgba(144, 143, 160, 0.25); - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 11px; - line-height: 16px; - font-weight: 500; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -.badge-processing { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 8px; - border-radius: 9999px; - background-color: rgba(192, 193, 255, 0.10); - color: #c0c1ff; - border: 1px solid rgba(192, 193, 255, 0.25); - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 11px; - line-height: 16px; - font-weight: 500; - letter-spacing: 0.06em; - text-transform: uppercase; - animation: pulse-badge 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; -} - -.badge-error { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 2px 8px; - border-radius: 9999px; - background-color: rgba(255, 180, 171, 0.10); - color: #ffb4ab; - border: 1px solid rgba(255, 180, 171, 0.25); - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 11px; - line-height: 16px; - font-weight: 500; - letter-spacing: 0.06em; - text-transform: uppercase; -} - -/* Dot inside badge */ -.badge-dot { - width: 6px; - height: 6px; - border-radius: 9999px; - flex-shrink: 0; - display: inline-block; -} -.badge-dot--positive { background-color: #4edea3; } -.badge-dot--negative { background-color: #ffb4ab; } -.badge-dot--neutral { background-color: #908fa0; } -.badge-dot--primary { background-color: #c0c1ff; } -.badge-dot--error { background-color: #ffb4ab; } - -/* ── 8. Highlights (annotated text) ─────────────────────────────────────────── */ -/* - * Applied by the server-side annotated-text builder (Phase 3) to wrap - * aspect spans inside the review text. - */ - -.highlight-positive { - background-color: rgba(78, 222, 163, 0.15); - color: #4edea3; - border: 1px solid rgba(78, 222, 163, 0.30); - border-radius: 4px; - padding: 0 4px; - margin: 0 2px; - font-weight: 500; -} - -.highlight-negative { - background-color: rgba(255, 180, 171, 0.15); - color: #ffb4ab; - border: 1px solid rgba(255, 180, 171, 0.30); - border-radius: 4px; - padding: 0 4px; - margin: 0 2px; - font-weight: 500; -} - -.highlight-neutral { - background-color: rgba(144, 143, 160, 0.15); - color: #c7c4d7; - border: 1px solid rgba(144, 143, 160, 0.25); - border-radius: 4px; - padding: 0 4px; - margin: 0 2px; - font-weight: 500; -} - -/* ── 9. Cards ────────────────────────────────────────────────────────────────── */ - -.card { - background-color: #171f33; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.08); - padding: 24px; -} - -.card-low { - background-color: #131b2e; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.06); - padding: 24px; -} - -.stat-card { - background-color: #171f33; - border-radius: 12px; - border: 1px solid rgba(255, 255, 255, 0.08); - padding: 24px; - position: relative; - overflow: hidden; -} - -/* ── 10. Form controls ──────────────────────────────────────────────────────── */ - -.input-base { - background-color: #0b1326; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 8px; - padding: 8px 12px; - font-size: 14px; - line-height: 20px; - color: #dae2fd; - width: 100%; - transition: border-color 150ms ease, box-shadow 150ms ease; - appearance: none; - -webkit-appearance: none; -} - -.input-base::placeholder { - color: rgba(199, 196, 215, 0.60); -} - -.input-base:focus { - outline: none; - border-color: #c0c1ff; - box-shadow: 0 0 0 1px rgba(192, 193, 255, 0.40); -} - -/* Select arrow */ -select.input-base { - 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"); - background-repeat: no-repeat; - background-position: right 10px center; - padding-right: 32px; - cursor: pointer; -} - -/* Textarea */ -textarea.input-base { - resize: vertical; - font-family: inherit; - line-height: 1.6; -} - -/* ── 11. Button — primary ────────────────────────────────────────────────────── */ - -.btn-primary { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - background-color: #c0c1ff; - color: #1000a9; - font-family: 'JetBrains Mono', ui-monospace, monospace; - font-size: 12px; - line-height: 16px; - font-weight: 500; - letter-spacing: 0.05em; - padding: 10px 20px; - border-radius: 8px; - border: none; - cursor: pointer; - text-decoration: none; - transition: filter 150ms ease, transform 150ms ease; - white-space: nowrap; -} -.btn-primary:hover { filter: brightness(1.10); } -.btn-primary:active { transform: scale(0.98); } -.btn-primary:disabled, -.btn-primary[disabled] { - opacity: 0.50; - cursor: not-allowed; - transform: none; - pointer-events: none; -} - -/* ── 12. Drag-and-drop ──────────────────────────────────────────────────────── */ - -.drag-active { - border-color: rgba(192, 193, 255, 0.70) !important; - background-color: rgba(192, 193, 255, 0.04) !important; -} - -/* ── 13. HTMX indicators ────────────────────────────────────────────────────── */ -/* - * HTMX adds .htmx-request to the element that triggered the request. - * Elements with .htmx-indicator are hidden by default and shown during request. - */ -.htmx-indicator { display: none; } -.htmx-request .htmx-indicator { display: flex; } -.htmx-request.htmx-indicator { display: flex; } - -/* Progress bar fill animation */ -.progress-bar { - transition: width 500ms ease; -} - -/* ── 14. Animations ──────────────────────────────────────────────────────────── */ - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -@keyframes slideIn { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: translateY(0); } -} - -@keyframes pulse-badge { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.6; } -} - -@keyframes pulse-dot { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -.animate-fade-in { animation: fadeIn 0.20s ease-out; } -.animate-slide-in { animation: slideIn 0.25s ease-out; } -.animate-spin { animation: spin 1s linear infinite; } -.animate-pulse-slow { animation: pulse-dot 3s cubic-bezier(0.4, 0, 0.6, 1) infinite; } - -/* ── 15. Toast notifications ─────────────────────────────────────────────────── */ -/* - * Toasts are managed by Alpine.js appState(). - * Base styles here; position and z-index are set with Tailwind utilities in base.html. - */ -.toast { - padding: 12px 16px; - border-radius: 8px; - border: 1px solid rgba(255, 255, 255, 0.08); - background-color: #222a3d; - color: #dae2fd; - font-size: 14px; - max-width: 380px; - pointer-events: auto; - transition: opacity 150ms ease, transform 150ms ease; -} -.toast--success { border-color: rgba(78, 222, 163, 0.30); color: #4edea3; } -.toast--error { border-color: rgba(255, 180, 171, 0.30); color: #ffb4ab; } -.toast--info { border-color: rgba(192, 193, 255, 0.20); color: #dae2fd; } diff --git a/app/templates/base.html b/app/templates/base.html deleted file mode 100644 index e0550d157d530aab97d2579ab1c722a5809cf495..0000000000000000000000000000000000000000 --- a/app/templates/base.html +++ /dev/null @@ -1,449 +0,0 @@ - - - - - - {% block title %}{{ page_title }}{% endblock %} — SentimentAI - - - - {# ── Fonts ────────────────────────────────────────────────────────────────── #} - {# Inter replaces Geist (same design language, available on Google Fonts CDN). #} - {# JetBrains Mono is used verbatim from the original tailwind.config.js. #} - - - - - {# ── Material Symbols Outlined icon font ──────────────────────────────────── #} - {# Variable-font version so FILL and wght axes are available (matching React). #} - - - {# - ── Tailwind CSS (Play CDN) ─────────────────────────────────────────────── - The CDN version scans the DOM at runtime and generates utilities on demand. - This eliminates the npm build step. Custom design tokens from the original - tailwind.config.js are provided in the tailwind.config object below. - - NOTE: The config - - - {# ── Custom design system CSS ──────────────────────────────────────────────── #} - {# Component classes that use @apply in the React version are written as plain #} - {# CSS in app.css (since the Play CDN does not process @apply in external CSS). #} - - - {# ── Per-page extra head content ──────────────────────────────────────────── #} - {% block head %}{% endblock %} - - - - - {# ── Toast notification container ────────────────────────────────────────── #} - {# Managed by Alpine.js appState(). HTMX error events dispatch to this. #} -
- -
- - {# ── Page wrapper ─────────────────────────────────────────────────────────── #} -
- - {# ── Mobile sidebar overlay ──────────────────────────────────────────────── #} - - - {# ── Sidebar ─────────────────────────────────────────────────────────────── #} - {# - The sidebar is always visible on desktop (md:translate-x-0) and slides in - on mobile when sidebarOpen is true. Alpine.js toggles the translation via - :class binding. The safelist in tailwind.config ensures translate-x-0 and - -translate-x-full are always generated by the CDN. - #} - - - {# ── Main area (right of sidebar) ────────────────────────────────────────── #} -
- - {# Top header bar #} -
- -
- {# Mobile hamburger #} - - - {# Mobile brand (hidden on desktop) #} -
- - SentimentAI -
- - {# Desktop page title (hidden on mobile) #} - -
- - {# Right action group #} -
- - - - - {# Avatar placeholder #} - -
-
- - {# Page content area #} -
-
- {% block content %}{% endblock %} -
-
- - {# Footer #} - - -
{# /main area #} -
{# /page wrapper #} - - {# ── JavaScript ────────────────────────────────────────────────────────────── #} - - {# - Alpine.js state — defined BEFORE the defer script so appState() is in scope - when Alpine.js initialises after DOMContentLoaded. - #} - - - {# HTMX — loaded deferred so it does not block rendering #} - - - {# Alpine.js v3 — loaded deferred; appState() above is already in global scope #} - - - {# ── Per-page extra scripts ───────────────────────────────────────────────── #} - {% block scripts %}{% endblock %} - - - diff --git a/app/templates/macros/ui.html b/app/templates/macros/ui.html deleted file mode 100644 index da0e2b1066d93899f2ef1c8525e640c33ad40137..0000000000000000000000000000000000000000 --- a/app/templates/macros/ui.html +++ /dev/null @@ -1,178 +0,0 @@ -{# - macros/ui.html — Reusable Jinja2 macros for the SentimentAI dashboard. - - WHY THIS FILE EXISTS - -------------------- - The React components (Sidebar.jsx, Monitor.jsx, Analytics.jsx) defined small - helper components (MSIcon, StatusBadge, HealthChip, InfoRow) that were reused - across pages. Jinja2 macros provide the equivalent pattern: define once, - import anywhere. - - USAGE IN TEMPLATES - ------------------ - {% from "macros/ui.html" import ms_icon, status_badge, health_chip, sentiment_badge %} - - Each macro produces self-contained HTML with no JavaScript dependencies. - CSS classes reference app.css component classes (badge-*, highlight-*) and - Tailwind utilities from the CDN. -#} - -{# ── Material Symbols Outlined icon ───────────────────────────────────────── #} -{# - ms_icon(name, filled, size, cls) - --------------------------------- - Renders a Material Symbols Outlined icon span. - - Parameters - ---------- - name : str — icon name e.g. "psychology", "cloud_upload", "bolt" - filled : bool — whether to use FILL=1 (solid) or FILL=0 (outlined) - size : int — font-size in px (default 20) - cls : str — extra CSS classes appended to the span -#} -{% macro ms_icon(name, filled=False, size=20, cls='') %} - -{% endmacro %} - - -{# ── Status badge ─────────────────────────────────────────────────────────── #} -{# - status_badge(status) - --------------------- - Renders a coloured badge for a batch job status. - - status values: "completed" | "processing" | "queued" | "failed" -#} -{% macro status_badge(status) %} -{% if status == 'completed' %} - - - Completed - -{% elif status == 'processing' %} - - - Processing - -{% elif status == 'queued' %} - - - Queued - -{% elif status == 'failed' %} - - - Failed - -{% else %} -{{ status }} -{% endif %} -{% endmacro %} - - -{# ── Health chip ──────────────────────────────────────────────────────────── #} -{# - health_chip(ok) - ---------------- - Renders a "Healthy" or "Degraded" status chip. - - ok : bool — True if the API health check returned status=="ok" -#} -{% macro health_chip(ok) %} -{% if ok %} - - - Healthy - -{% else %} - - - Degraded - -{% endif %} -{% endmacro %} - - -{# ── Sentiment badge ──────────────────────────────────────────────────────── #} -{# - sentiment_badge(sentiment) - --------------------------- - Renders the sentiment label for an aspect card. - - sentiment values: "positive" | "negative" | "neutral" | "conflict" -#} -{% macro sentiment_badge(sentiment) %} -{% if sentiment == 'positive' %} - - - {{ sentiment }} - -{% elif sentiment == 'negative' %} - - - {{ sentiment }} - -{% elif sentiment == 'conflict' %} - - - {{ sentiment }} - -{% else %} - - - {{ sentiment }} - -{% endif %} -{% endmacro %} - - -{# ── Info row (used on Monitor page) ─────────────────────────────────────── #} -{# - info_row(label, value, value_cls) - ----------------------------------- - Renders a labelled key-value cell inside the Model Configuration card. -#} -{% macro info_row(label, value, value_cls='') %} -
-

{{ label }}

-

{{ value }}

-
-{% endmacro %} - - -{# ── Loaded badge (aspect/sentiment model status) ────────────────────────── #} -{% macro loaded_badge() %} -
- {{ ms_icon('check_circle', filled=False, size=16, cls='text-[#4edea3]') }} - Loaded -
-{% endmacro %} - - -{# ── Skeleton placeholder (used during HTMX loading states) ─────────────── #} -{# - skeleton(height, width_cls) - ---------------------------- - Renders a pulsing skeleton placeholder matching the React Skeleton component. -#} -{% macro skeleton(height='h-4', width_cls='w-full') %} -
-{% endmacro %} - - -{# ── Empty-state panel ────────────────────────────────────────────────────── #} -{# - empty_state(icon, message) - --------------------------- - Centred icon + message for panels with no data yet. -#} -{% macro empty_state(icon='psychology', message='No data yet') %} -
- -

{{ message }}

-
-{% endmacro %} diff --git a/app/templates/pages/batch.html b/app/templates/pages/batch.html deleted file mode 100644 index ce3f55e7c81a9813b725e44322e3b117fa15a604..0000000000000000000000000000000000000000 --- a/app/templates/pages/batch.html +++ /dev/null @@ -1,124 +0,0 @@ -{% extends "base.html" %} -{% from "macros/ui.html" import ms_icon, status_badge, empty_state %} - -{% block title %}Batch Analytics{% endblock %} -{% block description %}Upload a CSV of reviews for bulk aspect-based sentiment analysis.{% endblock %} - -{% block content %} -{# - Phase 2: Placeholder layout for the Batch Analytics page. - - What this page will contain after Phase 4: - - Upload zone : drag-and-drop CSV input (HTML5 + Alpine.js drag events) - - Progress card: job status + progress bar polling via hx-trigger="every 2s" - - Recent table : DB-queried list of past batch jobs - - Charts : post-completion AspectHeatmap, LanguagePie, SentimentChart - (Chart.js, rendered with server-provided JSON data) - - The page structure below mirrors Analytics.jsx exactly so Phase 4 only needs to - replace placeholder content with functional forms and HTMX targets. -#} -
- - {# ── Page header ─────────────────────────────────────────────────────────── #} -
-

Batch Analytics

-

- Upload a CSV of reviews for bulk aspect-based sentiment analysis. -

-
- - {# ── Upload zone ──────────────────────────────────────────────────────────── #} -
-
- -
- - {{ ms_icon('cloud_upload', size=48, cls='text-[#c7c4d7] mb-4') }} -

-

- Must contain a text column. - Maximum 10,000 rows. Files are deleted after analysis. -

-
- - - -
- -
-
- - {# ── Recent batches table placeholder ─────────────────────────────────────── #} -
-

Recent Batches

-
-
- - - - - - - - - - - {# Phase 2: static mock rows matching React's Analytics.jsx mock data #} - {% for row in [ - {'name': 'q3_customer_feedback.csv', 'rows': '4,250', 'status': 'completed', 'date': 'Today, 14:32'}, - {'name': 'product_launch_tweets.csv', 'rows': '8,912', 'status': 'processing', 'date': 'Today, 14:15'}, - {'name': 'corrupted_export_09.csv', 'rows': '—', 'status': 'failed', 'date': 'Yesterday'}, - ] %} - - - - - - - {% endfor %} - -
FilenameRowsStatusDate
-
- {{ ms_icon('description', size=16, cls='text-[#c7c4d7]') }} - {{ row.name }} -
-
{{ row.rows }}{{ status_badge(row.status) }}{{ row.date }}
-
-
-
- -
-{% endblock %} diff --git a/app/templates/pages/monitor.html b/app/templates/pages/monitor.html deleted file mode 100644 index b3f9c982d4e117e0403d513caecdd9897c30493c..0000000000000000000000000000000000000000 --- a/app/templates/pages/monitor.html +++ /dev/null @@ -1,161 +0,0 @@ -{% extends "base.html" %} -{% from "macros/ui.html" import ms_icon, health_chip, info_row, loaded_badge %} - -{% block title %}System Monitor{% endblock %} -{% block description %}Real-time API health, model metadata, and request statistics.{% endblock %} - -{% block content %} -{# - Phase 2: Placeholder layout for the System Monitor page. - - What this page will contain after Phase 5: - - API Status card : live health chip polled via hx-trigger="every 30s" - - Model Configuration : architecture, languages, model status - - Performance Metrics : stat cards with SVG sparklines - - Recent Activity table : last N API requests - - All section structures below match Monitor.jsx exactly so Phase 5 only needs to - add live data and HTMX polling attributes. -#} -
- - {# ── Page header ─────────────────────────────────────────────────────────── #} -
-
-

System Monitor

-

- Real-time API health, model metadata, and request statistics. -

-
-
- - -
-
- - {# ── Health + Model config ────────────────────────────────────────────────── #} -
- - {# API Status card #} -
-
-
- {{ ms_icon('monitor_heart', size=24, cls='text-[#4edea3]') }} -
-
-

API Status

-

Core Inference Engine

-
-
- - {# Health status — HTMX polling target in Phase 5 #} - {% include "partials/monitor_health.html" %} -
- - {# Model Configuration card #} -
-
-
- {{ ms_icon('memory', size=24, cls='text-[#c0c1ff]') }} -
-
-

Model Configuration

-

Loaded ONNX Graphs

-
-
-
- {{ info_row('Architecture', 'XLM-RoBERTa (INT8)') }} - {{ info_row('Supported Languages', 'English, Hindi, Hinglish') }} -
-

Aspect Extraction

- {{ loaded_badge() }} -
-
-

Sentiment Classification

- {{ loaded_badge() }} -
-
-
-
- - {# ── Performance metrics (stat cards) ─────────────────────────────────────── #} -
-

Performance Metrics

-
- - {# Macro-style stat card — defined inline since it's used only here #} - {% for stat in [ - {'icon': 'database', 'label': 'Total Requests Today', 'value': '12.4k', 'sub': '↑ 8% vs yesterday', 'color': '#c0c1ff', 'positive': true}, - {'icon': 'bolt', 'label': 'Avg Latency (P95)', 'value': '145ms', 'sub': 'Well within SLA', 'color': '#4edea3', 'positive': true}, - {'icon': 'warning', 'label': 'Error Rate', 'value': '0.2%', 'sub': 'Last 24 hours', 'color': '#ffb4ab', 'positive': false}, - ] %} -
- {# Sparkline gradient background #} -
- -
-
-
- - {{ stat.label }} -
-

{{ stat.value }}

-

{{ stat.sub }}

-
-
- {% endfor %} - -
-
- - {# ── Recent endpoint activity ──────────────────────────────────────────────── #} -
-

Recent Endpoint Activity

-
- {% for req in [ - {'method': 'POST', 'path': '/predict', 'status': 200, 'time': '3.5ms', 'ago': '2s ago'}, - {'method': 'GET', 'path': '/health', 'status': 200, 'time': '0.8ms', 'ago': '5s ago'}, - {'method': 'POST', 'path': '/batch', 'status': 202, 'time': '12.1ms', 'ago': '1m ago'}, - {'method': 'GET', 'path': '/status/abc12', 'status': 200, 'time': '1.2ms', 'ago': '1m ago'}, - {'method': 'POST', 'path': '/predict', 'status': 500, 'time': '23ms', 'ago': '3m ago'}, - ] %} -
- - {{ req.method }} - - {{ req.path }} - - {{ req.status }} - - {{ req.time }} - -
- {% endfor %} -
- - -
- -
-{% endblock %} diff --git a/app/templates/pages/predict.html b/app/templates/pages/predict.html deleted file mode 100644 index 6a25ab581de197d3370fb46a50b855f3ed1e9ffa..0000000000000000000000000000000000000000 --- a/app/templates/pages/predict.html +++ /dev/null @@ -1,107 +0,0 @@ -{% extends "base.html" %} -{% from "macros/ui.html" import ms_icon, empty_state %} - -{% block title %}Live Predictor{% endblock %} -{% block description %}Analyze aspect-based sentiment in English and Hindi text in real time.{% endblock %} - -{% block content %} -{# - Phase 2: Placeholder layout for the Predict page. - - What this page will contain after Phase 3: - - Left panel : textarea input + language selector + Analyze button - - Right panel : annotated result text + aspect cards (HTMX swap target) - - POST /predict/fragment → returns partials/predict_result.html - - The outer grid, headings, and card shells are already correct here so Phase 3 - only needs to fill in the form and wire up HTMX — no structural changes. -#} -
- - {# ── Page header ─────────────────────────────────────────────────────────── #} -
-

Live Sentiment Predictor

-

- Enter text to analyze its aspects and sentiments in real-time. - The model automatically identifies the language and extracts key phrases. -

-
- - {# ── Two-column layout (mirrors LivePredictor.jsx structure) ─────────────── #} -
- - {# ── Left: Input panel ───────────────────────────────────────────────── #} -
-
- -
-

- Analyze Input -

-
- - -
-
- -
- - -
- ⌘ Enter to analyze - 0 / 512 -
-
- - - -
-
- - {# ── Right: Results panel ─────────────────────────────────────────────── #} -
-
- -
-

- Analysis Results -

-
- - {# Phase 2 placeholder — replaced by HTMX partial in Phase 3 #} -
- {{ empty_state('psychology', 'Enter a review and click Analyze to see results') }} -
- -
-
- -
{# /grid #} - - - -
-{% endblock %} diff --git a/app/templates/partials/batch_charts.html b/app/templates/partials/batch_charts.html deleted file mode 100644 index 9f7f524a12cce2a9d94aed7752996708d0208e3d..0000000000000000000000000000000000000000 --- a/app/templates/partials/batch_charts.html +++ /dev/null @@ -1,114 +0,0 @@ -{% if error %} -
-

Failed to load charts: {{ error }}

-
-{% else %} - -
- - {# Aspect Heatmap (Stacked Bar) #} -
-

Top Aspects by Sentiment

-
- -
-
- - {# Language Pie #} -
-

Language Distribution

-
- -
-
- - {# Sentiment Chart (Line) #} -
-

Sentiment Over Time (Row Chunks)

-
- -
-
- -
- - -{% endif %} diff --git a/app/templates/partials/batch_progress.html b/app/templates/partials/batch_progress.html deleted file mode 100644 index 2adcf8b822ab942ec881785b5cc52bde4a7f3ae0..0000000000000000000000000000000000000000 --- a/app/templates/partials/batch_progress.html +++ /dev/null @@ -1,51 +0,0 @@ -{% from "macros/ui.html" import ms_icon %} - -
- {% if error %} -
- {{ ms_icon('error', size=48, cls='opacity-80') }} -

{{ error }}

-
- {% else %} -
-
-

Batch Job: {{ job.job_id }}

-

Status: {{ job.status }}

-
- {% if job.status == 'completed' %} - - {{ ms_icon('download', size=18) }} - Download Results - - {% elif job.status == 'failed' %} - {{ ms_icon('error', size=16) }} Failed - {% else %} -
- autorenew - Processing... -
- {% endif %} -
- -
- {% set percent = (job.processed / job.total_reviews * 100) if job.total_reviews > 0 else 0 %} -
-
- -
- {{ job.processed }} processed - {{ job.total_reviews }} total -
- - {% if job.status in ['queued', 'processing'] %} -
- {% elif job.status == 'completed' %} -
-
- analytics - Generating charts... -
-
- {% endif %} - {% endif %} -
diff --git a/app/templates/partials/monitor_health.html b/app/templates/partials/monitor_health.html deleted file mode 100644 index 3aa191e978f9f4366fff60b894cc811381570e7e..0000000000000000000000000000000000000000 --- a/app/templates/partials/monitor_health.html +++ /dev/null @@ -1,19 +0,0 @@ -{% from "macros/ui.html" import health_chip %} - -
- Current state: - - {% if error %} - - - Service Unavailable - - {% else %} - {{ health_chip(health.status == 'ok') }} - {% endif %} - Updating... -
diff --git a/app/templates/partials/predict_result.html b/app/templates/partials/predict_result.html deleted file mode 100644 index 91d9a3b6aecaedebc7bccd0f62a429f8237e4af6..0000000000000000000000000000000000000000 --- a/app/templates/partials/predict_result.html +++ /dev/null @@ -1,101 +0,0 @@ -{% from "macros/ui.html" import ms_icon, sentiment_badge, empty_state %} - -{% if error %} -
- {{ ms_icon('error', size=48, cls='opacity-80') }} -

{{ error }}

-
-{% elif result %} - {# Macro to highlight text based on aspect positions #} - {% macro render_annotated_text(text, aspects) %} - {# - For Phase 3 we do a simplified highlight. - In a real template, we'd slice the text by start_pos/end_pos. - For now, we just print the text, as doing complex string slicing in Jinja is hard. - Wait, we can pass an 'annotated_text' pre-computed from the router, but the prompt says "do NOT duplicate business logic". - Let's just output the text, or if possible, use JS or a simple replace. - Actually, the user expects "highlight-positive" etc. from the CSS we wrote in Phase 2. - #} -
{{ text }}
- {% endmacro %} - -
- - {# ── Header metrics ── #} -
-
-
- {{ ms_icon('language', size=16) }} - {{ result.detected_language | upper }} -
-
- {{ ms_icon('timer', size=16) }} - {{ result.processing_time_ms | round }}ms -
-
-
- - {# ── Annotated Text ── #} -
-
{{ result.text }}
-
- - {# ── Aspects List ── #} -
-

Detected Aspects

- {% if result.aspects %} -
- {% for aspect in result.aspects %} -
- {{ aspect.aspect }} -
- conf: {{ "%.2f"|format(aspect.confidence) }} - {{ sentiment_badge(aspect.sentiment) }} -
-
- {% endfor %} -
- {% else %} -
-

No aspects detected in this text.

-
- {% endif %} -
- - {# ── Client-side Text Highlighting ── #} - {# Uses DOM API to safely highlight aspect spans — never innerHTML with user data #} - - -
-{% else %} - {{ empty_state('psychology', 'Enter a review and click Analyze to see results') }} -{% endif %} diff --git a/docker/Dockerfile b/docker/Dockerfile index 62ea45fd752ca7609979445fb323c10483cdca14..2c80af27b9f111a671b155474b00ccedc5bd022d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -2,14 +2,16 @@ FROM python:3.11-slim AS builder WORKDIR /app -COPY requirements.txt . +COPY pyproject.toml . +COPY src ./src +COPY api ./api RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ git \ && rm -rf /var/lib/apt/lists/* -RUN pip install --no-cache-dir --prefix=/install -r requirements.txt +RUN pip install --no-cache-dir --prefix=/install . # Stage 2: Runtime FROM python:3.11-slim @@ -20,13 +22,15 @@ WORKDIR /app COPY --from=builder /install /usr/local # Copy application code -COPY app /app/app +COPY api /app/api +COPY src /app/src COPY scripts /app/scripts -COPY absa /app/absa COPY docker /app/docker COPY .env.example /app/.env.example # .env is injected via docker-compose environment vars — no need to COPY it +ENV PYTHONPATH=/app/src + # Add a non-root user RUN adduser --disabled-password --gecos "" absauser \ && chown -R absauser /app @@ -35,4 +39,4 @@ USER absauser EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/docker/Dockerfile.prod b/docker/Dockerfile.prod index 723da770e535a6da0d7422592a47414ce80a7c3a..899d9bf8d2bc46ef019c760c9cb0d1f06d1ef6f6 100644 --- a/docker/Dockerfile.prod +++ b/docker/Dockerfile.prod @@ -1,17 +1,19 @@ FROM python:3.11-slim as builder WORKDIR /app -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt +COPY pyproject.toml . +COPY src ./src +COPY api ./api +RUN pip install --no-cache-dir . FROM python:3.11-slim as runtime WORKDIR /app COPY --from=builder /usr/local/lib/python3.11 /usr/local/lib/python3.11 COPY --from=builder /usr/local/bin /usr/local/bin -COPY app/ ./app/ -COPY absa/ ./absa/ -ENV PYTHONPATH=/app +COPY api/ ./api/ +COPY src/ ./src/ +ENV PYTHONPATH=/app/src ENV MODEL_SOURCE=huggingface_hub RUN useradd -m appuser && chown -R appuser /app USER appuser EXPOSE 8000 -CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] +CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 07e32b71d0faa08b781260332e13ff4ea463cf82..76f1731036ff08a39420cb69762e0d67746e9b3a 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -25,7 +25,7 @@ services: context: ../ dockerfile: Dockerfile container_name: absa-worker - command: ["celery", "-A", "app.tasks", "worker", "--loglevel=info"] + command: ["celery", "-A", "api.tasks", "worker", "--loglevel=info"] environment: - DATABASE_URL=${DATABASE_URL} - REDIS_URL=${REDIS_URL} diff --git a/docs/HTMX_MIGRATION.md b/docs/HTMX_MIGRATION.md deleted file mode 100644 index 8114ac19a61b85b4e5cdd107047276b1533aabb2..0000000000000000000000000000000000000000 --- a/docs/HTMX_MIGRATION.md +++ /dev/null @@ -1,92 +0,0 @@ -# HTMX Migration - -## Summary - -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. - -## What Changed - -| Before | After | -|--------|-------| -| Streamlit frontend at `streamlit_app/` | HTMX + Jinja2 at `api/app/templates/` | -| Two servers: FastAPI (8000) + Streamlit (8501) | Single FastAPI server (8000) | -| `streamlit run streamlit_app/Home.py` | `uvicorn app.main:app --reload` | -| Plotly for charts | Chart.js (client-side rendering) | -| Streamlit state management | Alpine.js + HTMX for interactivity | - -## Architecture - -``` -User Browser - ↕ HTMX / Alpine.js -FastAPI (port 8000) - ├── /predict → Predict page (HTML via Jinja2) - ├── /batch → Batch upload page (HTML) - ├── /monitor → System monitor page (HTML) - ├── /docs → Swagger UI (unchanged) - ├── /api/predict → JSON API (unchanged) - ├── /api/batch → JSON API (unchanged) - ├── /predict/fragment → HTMX fragment (HTML partial) - ├── /batch/fragment → HTMX fragment (HTML partial) - └── /api/batch/progress/{job_id} → SSE endpoint -``` - -## Frontend Stack - -- **HTMX 2.0.3** - AJAX, CSS transitions, WebSocket/SSE -- **Alpine.js 3.14** - Reactive UI state (toasts, sidebar) -- **Tailwind CSS (Play CDN)** - Utility-first CSS -- **Chart.js** - Client-side charts (batch results) -- **Material Symbols** - Icon font -- **itsdangerous** - CSRF protection - -## CSRF Protection - -All HTMX form endpoints require a CSRF token. The token is: -- Set as an `HttpOnly` cookie on every GET response -- Injected into `` in `base.html` -- Automatically attached to HTMX requests via `htmx:configRequest` event handler -- Validated by `CSRFMiddleware` for all non-GET, non-API requests - -## File Structure - -``` -api/app/ -├── templates/ -│ ├── base.html # Base layout with nav, sidebar, toast system -│ ├── pages/ -│ │ ├── predict.html # Single review prediction form -│ │ ├── batch.html # CSV upload + progress + results -│ │ └── monitor.html # Health stats + performance metrics -│ ├── partials/ -│ │ ├── predict_result.html # Prediction result card -│ │ ├── batch_progress.html # Batch job progress bar -│ │ ├── batch_charts.html # Chart.js charts -│ │ └── monitor_health.html # Health status chip -│ └── macros/ -│ └── ui.html # Reusable components (badges, icons, etc.) -├── static/ -│ └── css/ -│ └── app.css # Design system components -├── core/ -│ └── templates.py # Centralized Jinja2Templates instance -├── middleware/ -│ └── csrf.py # CSRF protection middleware -└── main.py # FastAPI app entry point -``` - -## Deleted Files - -- `streamlit_app/` (entire directory) -- `config/docker/Dockerfile.streamlit` -- Streamlit service in `config/docker/docker-compose.yml` -- Streamlit dependency from `requirements.txt` -- Plotly dependency from `requirements.txt` - -## Verification - -- All page routes return HTML at `/predict`, `/batch`, `/monitor` -- JSON API endpoints remain at `/api/predict`, `/api/batch` -- Swagger UI at `/docs` is unchanged -- HTMX endpoints return HTML fragments (no page reload) -- SSE endpoint for live batch progress at `/api/batch/progress/{job_id}` diff --git a/dvc.yaml b/dvc.yaml index d9a1c583449ccbadd0f1fa65f45b9d63923cc35c..dd43678d6937953eaf5b66855471343688fba243 100644 --- a/dvc.yaml +++ b/dvc.yaml @@ -1,11 +1,11 @@ stages: preprocess_semeval: - cmd: PYTHONPATH=. python absa/data/dataset.py + cmd: PYTHONPATH=src python -m absa.data.dataset deps: - - absa/data/dataset.py - - absa/data/preprocess.py - - absa/data/lang_detect.py - - absa/data/transliterate.py + - src/absa/data/dataset.py + - src/absa/data/preprocess.py + - src/absa/data/lang_detect.py + - src/absa/data/transliterate.py - data/raw/semeval_restaurants - data/raw/semeval_laptops outs: @@ -13,12 +13,12 @@ stages: - data/processed/semeval_test.jsonl preprocess_hindi: - cmd: PYTHONPATH=. python absa/data/hindi_loader.py + cmd: PYTHONPATH=src python -m absa.data.hindi_loader deps: - - absa/data/hindi_loader.py - - absa/data/preprocess.py - - absa/data/lang_detect.py - - absa/data/transliterate.py + - src/absa/data/hindi_loader.py + - src/absa/data/preprocess.py + - src/absa/data/lang_detect.py + - src/absa/data/transliterate.py - data/raw/amazon_hindi/hindi_sentiment.jsonl outs: - data/processed/amazon_hindi.jsonl diff --git a/frontend/Home.py b/frontend/Home.py new file mode 100644 index 0000000000000000000000000000000000000000..0c4a95fc2ab0b08a0db2902cd5627ea4925e0c49 --- /dev/null +++ b/frontend/Home.py @@ -0,0 +1,57 @@ +"""Multilingual ABSA — Streamlit entry point. + +Navigation split: + • Analysis → the only screen regular users see (input comment → results). + • Admin → application status, batch analytics, system monitor. +""" + +from __future__ import annotations + +import streamlit as st +from ui import apply_theme + +st.set_page_config( + page_title="Multilingual ABSA", + page_icon="🌍", + layout="wide", + initial_sidebar_state="expanded", +) + +apply_theme() + +analyzer = st.Page( + "views/predict.py", + title="Sentiment Analyzer", + icon="💬", + url_path="predict", + default=True, +) + +admin_overview = st.Page( + "views/admin/overview.py", + title="Overview", + icon="📊", + url_path="admin", +) +admin_batch = st.Page( + "views/admin/batch.py", + title="Batch Analytics", + icon="📁", + url_path="batch", +) +admin_monitor = st.Page( + "views/admin/monitor.py", + title="System Monitor", + icon="🩺", + url_path="monitor", +) + +pg = st.navigation( + { + "Analysis": [analyzer], + "Admin": [admin_overview, admin_batch, admin_monitor], + }, + position="sidebar", +) + +pg.run() diff --git a/app/__init__.py b/frontend/__init__.py similarity index 100% rename from app/__init__.py rename to frontend/__init__.py diff --git a/frontend/absa_client.py b/frontend/absa_client.py new file mode 100644 index 0000000000000000000000000000000000000000..dfa3e078c6ea641886b2ff5ac7fa8f404749a28e --- /dev/null +++ b/frontend/absa_client.py @@ -0,0 +1,84 @@ +"""HTTP client for the Multilingual ABSA FastAPI backend. + +The Streamlit frontend never imports the ML pipeline directly — it talks to +the running FastAPI service (``API_BASE_URL``, default ``http://localhost:8000``) +over plain HTTP. This keeps the dashboard a thin, deployable UI layer. +""" + +from __future__ import annotations + +import os +from typing import Any, Optional + +import httpx +import streamlit as st + +API_BASE_URL: str = os.getenv("API_BASE_URL", "http://localhost:8000") + +_TIMEOUT: float = float(os.getenv("API_TIMEOUT", "60")) + + +class APIClient: + """Thin wrapper around the ABSA REST API with Streamlit-friendly errors.""" + + def __init__(self, base_url: str = API_BASE_URL, transport: Optional[httpx.BaseTransport] = None) -> None: + self.base_url = base_url.rstrip("/") + self._client = httpx.Client( + base_url=self.base_url, + timeout=_TIMEOUT, + follow_redirects=True, + transport=transport, + ) + + # ── Helpers ──────────────────────────────────────────────────────────── + + def _request(self, method: str, path: str, **kwargs: Any) -> Optional[Any]: + try: + response = self._client.request(method, path, **kwargs) + response.raise_for_status() + return response.json() + except httpx.HTTPStatusError as exc: + detail = exc.response.text + st.error(f"API error ({exc.response.status_code}): {detail}") + return None + except httpx.HTTPError as exc: + st.error(f"Cannot reach the ABSA API at `{self.base_url}` — is it running?\n\n{exc}") + return None + + def close(self) -> None: + self._client.close() + + # ── Endpoints ────────────────────────────────────────────────────────── + + def get_health(self) -> Optional[dict[str, str]]: + return self._request("GET", "/health") + + def get_info(self) -> Optional[dict[str, str]]: + return self._request("GET", "/info") + + def predict(self, text: str, language: str = "auto") -> Optional[dict[str, Any]]: + payload = {"text": text, "language": language if language != "auto" else None} + return self._request("POST", "/predict", json=payload) + + def upload_batch(self, file: Any) -> Optional[dict[str, Any]]: + files = {"file": (file.name, file.getvalue(), "text/csv")} + return self._request("POST", "/batch", files=files) + + def get_batch_status(self, job_id: str) -> Optional[dict[str, Any]]: + return self._request("GET", f"/status/{job_id}") + + def download_result(self, job_id: str) -> Optional[bytes]: + """Fetch the generated CSV bytes for a completed batch job.""" + try: + response = self._client.get(f"/download/{job_id}") + response.raise_for_status() + return response.content + except httpx.HTTPError as exc: + st.error(f"Failed to download results: {exc}") + return None + + +@st.cache_resource(show_spinner=False) +def get_client() -> APIClient: + """Return a process-wide cached API client (reused across reruns).""" + return APIClient() diff --git a/frontend/ui.py b/frontend/ui.py new file mode 100644 index 0000000000000000000000000000000000000000..b27c12246fc717151ee24127c33e3743dfa17e19 --- /dev/null +++ b/frontend/ui.py @@ -0,0 +1,251 @@ +"""Shared UI helpers for the Streamlit dashboard: theme, cards, badges.""" + +from __future__ import annotations + +import os +from typing import Iterable + +import streamlit as st + +SENTIMENT_COLORS: dict[str, str] = { + "positive": "#16a34a", + "negative": "#dc2626", + "neutral": "#64748b", + "conflict": "#9333ea", +} + +LANGUAGE_LABELS: dict[str, str] = { + "en": "English", + "hi": "Hindi", + "hinglish": "Hinglish", + "auto": "Auto-detect", +} + +_SAMPLE_REVIEWS: list[dict[str, str]] = [ + { + "label": "Battery + Screen (EN)", + "text": "The battery life is amazing but the screen is too dim.", + }, + { + "label": "Camera & Service (EN)", + "text": "Great camera quality, though the delivery was terribly slow.", + }, + { + "label": "Sound (HI)", + "text": "आवाज़ बहुत साफ़ है और बेस भी बढ़िया है।", + }, + { + "label": "Hinglish Mix", + "text": "Phone ka design badhiya hai lekin battery life kharab hai.", + }, +] + + +def apply_theme() -> None: + """Inject custom CSS: modern gradient hero, cards, badges, spacing.""" + st.markdown( + """ + + """, + unsafe_allow_html=True, + ) + + +def hero(title: str, subtitle: str) -> None: + """Render the gradient hero banner.""" + st.markdown( + f""" +
+

{title}

+

{subtitle}

+
+ """, + unsafe_allow_html=True, + ) + + +def feature_card(icon: str, title: str, description: str, accent: str = "#6366f1") -> None: + """Render a clickable-style feature card.""" + st.markdown( + f""" +
+
{icon}
+
{title}
+
{description}
+
+ """, + unsafe_allow_html=True, + ) + + +def sentiment_badge(sentiment: str) -> str: + """HTML for a colored sentiment badge.""" + color = SENTIMENT_COLORS.get(sentiment, "#64748b") + return f'{sentiment}' + + +def aspect_card(aspect: str, sentiment: str, confidence: float) -> str: + """HTML for a single aspect result card with a confidence bar.""" + color = SENTIMENT_COLORS.get(sentiment, "#64748b") + pct = max(0.0, min(100.0, float(confidence) * 100.0)) + return ( + f'
' + f'
{aspect}   {sentiment_badge(sentiment)}
' + f'
Confidence: {confidence:.2f}
' + f'
' + f"
" + ) + + +def render_aspects(aspects: Iterable[dict]) -> None: + """Render a list of aspect dicts as styled cards.""" + items = list(aspects) + if not items: + st.info("No aspects detected in this text.") + return + html = "".join( + aspect_card( + str(a.get("aspect", "N/A")), + str(a.get("sentiment", "neutral")), + float(a.get("confidence", 0.0)), + ) + for a in items + ) + st.markdown(html, unsafe_allow_html=True) + + +def language_options() -> list[str]: + return ["auto", "en", "hi", "hinglish"] + + +def sample_reviews() -> list[dict[str, str]]: + return _SAMPLE_REVIEWS + + +def require_admin() -> bool: + """Gate admin pages behind an optional password (env `ADMIN_PASSWORD`). + + When `ADMIN_PASSWORD` is empty the admin section is open; otherwise a + lock screen is shown until the correct password is entered. Callers should + `st.stop()` when this returns ``False``. + """ + password = os.getenv("ADMIN_PASSWORD", "") + if not password: + return True + if st.session_state.get("admin_ok"): + return True + + st.markdown( + """ +
+
🔒
+

Admin access

+

This section is restricted. Enter the admin + password to view application status and features.

+
+ """, + unsafe_allow_html=True, + ) + candidate = st.text_input("Admin password", type="password", key="admin_password") + if st.button("Unlock", type="primary", key="admin_unlock"): + if candidate == password: + st.session_state["admin_ok"] = True + st.rerun() + else: + st.error("Incorrect password.") + return False diff --git a/frontend/views/admin/batch.py b/frontend/views/admin/batch.py new file mode 100644 index 0000000000000000000000000000000000000000..4125dcd0bb3cec8459c1c9cded2ef7b809225ae2 --- /dev/null +++ b/frontend/views/admin/batch.py @@ -0,0 +1,118 @@ +"""Admin — batch analytics: CSV upload, live job progress, result download.""" + +from __future__ import annotations + +import io +import time + +import pandas as pd +import streamlit as st +from absa_client import get_client +from ui import apply_theme, hero, require_admin + +apply_theme() + +if not require_admin(): + st.stop() + +client = get_client() + +hero( + "Admin · Batch Analytics 📁", + "Upload a CSV of product reviews, queue an async batch job on the worker, " + "track progress live, and download the full annotated results.", +) + +col_up, col_hint = st.columns([2, 1], gap="large") + +with col_up: + uploaded = st.file_uploader("Upload a CSV file", type=["csv"], accept_multiple_files=False) + +with col_hint: + st.markdown( + """ +
+
Required format
+
A CSV with a text column. Limits:
+ +
+ """, + unsafe_allow_html=True, + ) + +df_preview = None +if uploaded is not None: + try: + df_preview = pd.read_csv(io.BytesIO(uploaded.getvalue())) + st.success(f"Loaded {len(df_preview)} rows with columns: {', '.join(df_preview.columns)}") + if "text" not in df_preview.columns: + st.error("CSV must contain a **text** column.") + df_preview = None + else: + st.dataframe(df_preview.head(5), use_container_width=True, hide_index=True) + except Exception as exc: + st.error(f"Could not parse CSV: {exc}") + + if df_preview is not None: + start = st.button("🚀 Start Batch Processing", type="primary", use_container_width=True) + + if start: + with st.spinner("Uploading and queuing job…"): + job = client.upload_batch(uploaded) + if job: + st.session_state["batch_job_id"] = job.get("job_id") + st.rerun() + +job_id = st.session_state.get("batch_job_id") +if job_id: + st.markdown("---") + st.subheader(f"Job progress — `{job_id[:8]}…`") + + progress = st.progress(0.0) + status = st.status("Queued…", expanded=True) + + while True: + job = client.get_batch_status(job_id) + if job is None: + st.error("Failed to fetch job status.") + break + + total = max(int(job.get("total_reviews") or 0), 1) + processed = int(job.get("processed") or 0) + ratio = min(processed / total, 1.0) + progress.progress(ratio) + + label = { + "queued": "Queued — waiting for a worker…", + "processing": f"Processing {processed}/{total} reviews…", + "completed": f"Completed — {processed}/{total} reviews analyzed ✅", + "failed": "Job failed ❌", + }.get(job.get("status"), job.get("status", "…")) + status.update( + label=label, + state="running" if job.get("status") in ("queued", "processing") else "complete", + ) + + if job.get("status") in ("completed", "failed"): + break + time.sleep(2) + + c1, c2, c3 = st.columns(3) + c1.metric("Total Reviews", job.get("total_reviews")) + c2.metric("Processed", job.get("processed")) + c3.metric("Status", job.get("status")) + + if job.get("status") == "completed": + result_bytes = client.download_result(job_id) + if result_bytes is not None: + st.download_button( + "⬇️ Download results (CSV)", + data=result_bytes, + file_name=f"absa_results_{job_id}.csv", + mime="text/csv", + type="primary", + use_container_width=True, + ) diff --git a/frontend/views/admin/monitor.py b/frontend/views/admin/monitor.py new file mode 100644 index 0000000000000000000000000000000000000000..6754832f42e387b6613f6e769778feb175d92491 --- /dev/null +++ b/frontend/views/admin/monitor.py @@ -0,0 +1,53 @@ +"""Admin — system monitor: health checks, service metadata, diagnostics.""" + +from __future__ import annotations + +import streamlit as st +from absa_client import get_client +from ui import apply_theme, hero, require_admin + +apply_theme() + +if not require_admin(): + st.stop() + +client = get_client() + +hero( + "Admin · System Monitor 🩺", + "Live health checks, model metadata, and API configuration for the ABSA service.", +) + +auto_refresh = st.toggle("Auto-refresh every 5s", value=False) + +col_h, col_i = st.columns(2) +with col_h: + health = client.get_health() + if health: + st.success("### API is healthy ✅") + for key, value in health.items(): + st.markdown(f"- **{key}**: `{value}`") + else: + st.error("### API unreachable ❌") + +with col_i: + info = client.get_info() + if info: + st.info("### Service info") + for key, value in info.items(): + st.markdown(f"- **{key}**: `{value}`") + +st.markdown("---") + +col_a, col_b, col_c = st.columns(3) +col_a.metric("Endpoint", client.base_url) +col_b.metric("Timeout (s)", "60") +col_c.metric("Languages", "en · hi · hinglish") + +st.caption( + "Tip: run the API with `uvicorn api.main:app --port 8000` and this " + "dashboard with `streamlit run streamlit_app/Home.py`." +) + +if auto_refresh: + st.rerun() diff --git a/frontend/views/admin/overview.py b/frontend/views/admin/overview.py new file mode 100644 index 0000000000000000000000000000000000000000..e82e9dbc6ea3bbdb7fea89f86879e0d054082ef0 --- /dev/null +++ b/frontend/views/admin/overview.py @@ -0,0 +1,98 @@ +"""Admin — overview: application status and feature details.""" + +from __future__ import annotations + +import streamlit as st +from absa_client import get_client +from ui import apply_theme, feature_card, hero, require_admin + +apply_theme() + +if not require_admin(): + st.stop() + +client = get_client() + +hero( + "Admin · Application Overview 📊", + "Status of the ABSA platform, model metadata, and everything available in this deployment.", +) + +# ── Live status strip ───────────────────────────────────────────────────────── +info = client.get_info() +health = client.get_health() +api_online = health is not None + +col1, col2, col3, col4 = st.columns(4) +col1.metric( + "API Status", + "Online" if api_online else "Offline", + delta="●" if api_online else "○", + delta_color="normal" if api_online else "off", +) +col2.metric( + "Database", + "connected" if api_online else "unknown", +) +col3.metric("Model", (info or {}).get("model_name", "xlm-roberta-base-absa")) +col4.metric("API Base URL", client.base_url) + +st.markdown("---") + +# ── Detailed status ─────────────────────────────────────────────────────────── +col_h, col_i = st.columns(2, gap="large") + +with col_h: + st.subheader("Health check") + if health: + for key, value in health.items(): + st.markdown(f"- **{key}**: `{value}`") + else: + st.error("API unreachable — start `uvicorn api.main:app --port 8000`.") + +with col_i: + st.subheader("Service info") + if info: + for key, value in info.items(): + st.markdown(f"- **{key}**: `{value}`") + else: + st.warning("No service info available.") + +st.markdown("---") + +# ── Feature details ─────────────────────────────────────────────────────────── +st.subheader("What's available") +col_a, col_b, col_c = st.columns(3) +with col_a: + feature_card( + "💬", + "Sentiment Analyzer", + "The public view — users paste a review and get aspect-level sentiment.", + ) +with col_b: + feature_card( + "📁", + "Batch Analytics", + "Upload a CSV of reviews, run async jobs, and download annotated results.", + ) +with col_c: + feature_card( + "🩺", + "System Monitor", + "Health checks, service metadata, and auto-refresh diagnostics.", + ) + +st.markdown( + """ +
+
REST API
+
+ All dashboard features are backed by the JSON API at /docs: +
+ POST /predict · POST /batch · + GET /status/{job_id} · GET /health · + GET /info · GET /metrics +
+ """, + unsafe_allow_html=True, +) diff --git a/frontend/views/predict.py b/frontend/views/predict.py new file mode 100644 index 0000000000000000000000000000000000000000..6d00632e2b2e8ec5bc4e4395b4af15e764b2c272 --- /dev/null +++ b/frontend/views/predict.py @@ -0,0 +1,67 @@ +"""User-facing view — the only screen regular users see. + +Type/paste a review, pick a language, get aspect-level sentiment back. +No dashboards, no metrics, no administration here. +""" + +from __future__ import annotations + +import streamlit as st +from absa_client import get_client +from ui import LANGUAGE_LABELS, apply_theme, language_options, render_aspects + +client = get_client() +apply_theme() + +st.markdown( + """ +
+

💬 Sentiment Analyzer

+

+ Paste a review or comment in English, Hindi, or Hinglish and get + aspect-level sentiment instantly. +

+
+ """, + unsafe_allow_html=True, +) + +# ── Input ───────────────────────────────────────────────────────────────────── +text = st.text_area( + "Your comment", + height=170, + placeholder="e.g. The camera is amazing but the battery drains too fast.", + key="review_text", +) + +col_lang, col_btn = st.columns([1, 2], gap="medium") +with col_lang: + language = st.selectbox( + "Language", + language_options(), + index=0, + format_func=lambda code: LANGUAGE_LABELS[code], + ) +with col_btn: + st.caption("Auto-detects the language if unsure.") + analyze = st.button( + "Analyze Sentiment", + type="primary", + use_container_width=True, + disabled=not text.strip(), + ) + +st.markdown("---") + +# ── Results ─────────────────────────────────────────────────────────────────── +if analyze and text.strip(): + with st.spinner("Analyzing…"): + result = client.predict(text.strip(), language) + + if result: + aspects = result.get("aspects") or [] + + st.subheader("Aspects found") + render_aspects(aspects) +else: + st.info("Enter a review above and press **Analyze Sentiment** to get started.") diff --git a/ml/notebooks/01_data_exploration.ipynb b/notebooks/01_data_exploration.ipynb similarity index 100% rename from ml/notebooks/01_data_exploration.ipynb rename to notebooks/01_data_exploration.ipynb diff --git a/ml/notebooks/03_model_comparison.ipynb b/notebooks/03_model_comparison.ipynb similarity index 100% rename from ml/notebooks/03_model_comparison.ipynb rename to notebooks/03_model_comparison.ipynb diff --git a/ml/notebooks/03_train_colab.ipynb b/notebooks/03_train_colab.ipynb similarity index 100% rename from ml/notebooks/03_train_colab.ipynb rename to notebooks/03_train_colab.ipynb diff --git a/ml/notebooks/04_qlora_colab.ipynb b/notebooks/04_qlora_colab.ipynb similarity index 100% rename from ml/notebooks/04_qlora_colab.ipynb rename to notebooks/04_qlora_colab.ipynb diff --git a/ml/notebooks/08_final_evaluation.ipynb b/notebooks/08_final_evaluation.ipynb similarity index 100% rename from ml/notebooks/08_final_evaluation.ipynb rename to notebooks/08_final_evaluation.ipynb diff --git a/pyproject.toml b/pyproject.toml index 8c35c91b4edc7fcf8e4d858102cc75f8470f06cc..c337fbe9d52c059b6f108d072314ed3d1e4a0c22 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,10 +1,10 @@ [build-system] requires = ["setuptools>=68.0"] -build-backend = "setuptools.backends._legacy:_Backend" +build-backend = "setuptools.build_meta" [project] name = "multilingual-absa" -version = "2.0.0" +version = "2.1.0" description = "Multilingual Aspect-Based Sentiment Analysis for English, Hindi, and Hinglish" readme = "README.md" license = {text = "MIT"} @@ -20,12 +20,67 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Text Processing :: Linguistic", ] +dependencies = [ + # ML / NLP + "transformers>=4.41.0", + "datasets>=2.19.0", + "torch>=2.3.0", + "onnxruntime>=1.18.0", + "optimum[onnxruntime]>=1.19.0", + "peft>=0.10.0", + "fasttext-predict>=0.9.2.4", + "indic-nlp-library @ git+https://github.com/anoopkunchukuttan/indic_nlp_library.git", + "nlpaug>=1.1.11", + # Data & Metrics + "scikit-learn>=1.4.2", + "pandas>=2.2.3", + "numpy>=1.26.4", + "seqeval>=1.2.2", + # Experiment tracking & pipelines + "mlflow>=2.15.0", + "dvc>=3.51.1", + "evidently>=0.4.30", + # API + "fastapi>=0.115.0", + "uvicorn>=0.29.0", + "python-multipart>=0.0.9", + "slowapi>=0.1.9", + "pydantic>=2.7.1", + "python-dotenv>=1.0.1", + "prometheus-fastapi-instrumentator>=7.0.0", + # Async workers + "celery>=5.4.0", + "redis>=5.0.4", + # Database + "psycopg2-binary>=2.9.9", + # Frontend (Streamlit dashboard — calls the FastAPI backend over HTTP) + "streamlit>=1.37.0", + "httpx>=0.28.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.2.0", + "pytest-asyncio>=0.23.7", + "pytest-cov>=5.0.0", + "coverage>=7.5.0", + "ruff>=0.6.0", + "mypy>=1.10.0", + "bandit>=1.7.9", + "radon>=6.0.1", + "scalene>=1.5.30", +] + +[tool.setuptools.packages.find] +where = ["src", "."] +include = ["absa*", "api*"] [tool.pytest.ini_options] testpaths = ["tests"] python_files = ["test_*.py"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +addopts = "--cov=api --cov=absa --cov-report=term-missing --cov-report=xml" [tool.ruff] target-version = "py310" @@ -33,7 +88,31 @@ line-length = 120 [tool.ruff.lint] select = ["E", "F", "I", "N", "W"] +extend-ignore = ["N999"] # module file name style + +[tool.mypy] +python_version = "3.10" +warn_return_any = true +warn_unused_configs = true +ignore_missing_imports = true +check_untyped_defs = true +exclude = [ + "tests/", + "scripts/", + "notebooks/", + "docs/", +] + +[tool.bandit] +exclude_dirs = ["tests", "scripts"] +targets = ["api", "absa"] +skips = ["B101"] # allow assert (pytest usage) [tool.coverage.run] -source = ["app", "absa"] +source = ["api", "absa"] omit = ["*/tests/*", "*/__pycache__/*"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +fail_under = 24 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index b26b99460396dea243a6ffa589ca6250aff5fafb..0000000000000000000000000000000000000000 --- a/requirements.txt +++ /dev/null @@ -1,54 +0,0 @@ -# ML / NLP — Pinned for reproducibility -transformers>=4.41.0 -datasets>=2.19.0 -torch>=2.3.0 -onnxruntime>=1.18.0 -optimum[onnxruntime]>=1.19.0 -peft>=0.10.0 -fasttext-predict>=0.9.2.4 -indic-nlp-library @ git+https://github.com/anoopkunchukuttan/indic_nlp_library.git -nlpaug>=1.1.11 - -# Data & Metrics -scikit-learn>=1.4.2 -pandas>=2.2.3 # CVE-2024-42992 fix -numpy>=1.26.4 -seqeval>=1.2.2 - -# MLflow & DVC -mlflow>=2.15.0 # Security improvements -dvc>=3.51.1 -evidently>=0.4.30 - -# API — Latest with fixes -fastapi>=0.115.0 # CVE-2024-24762 fix -jinja2>=3.1.5 # CVE-2024-56326 fix -uvicorn>=0.29.0 -python-multipart>=0.0.9 -slowapi>=0.1.9 # Rate limiting - -# Async Workers -celery>=5.4.0 -redis>=5.0.4 - -# Database -psycopg2-binary>=2.9.9 # Dev only; use psycopg2 (source) in production - -# Validation -pydantic>=2.7.1 -python-dotenv>=1.0.1 - -# Testing -pytest>=8.2.0 -httpx>=0.28.0 # Security fixes - -# Monitoring -prometheus-fastapi-instrumentator>=7.0.0 -slowapi>=0.1.9 - -# API Layer additions -sse-starlette>=2.1.3,<3.0.0 # Server-Sent Events for live progress (avoid starlette conflict) -itsdangerous>=2.2.0 # CSRF protection for HTMX forms - -# Testing -pytest-asyncio>=0.23.7 # Async test support diff --git a/scripts/download_data.py b/scripts/download_data.py index 4ef5db541abdeac095b2a88866675e692f597e7f..67277e4c5a0d393e815cb554c26b73141ae6b929 100644 --- a/scripts/download_data.py +++ b/scripts/download_data.py @@ -1,7 +1,7 @@ import os import urllib.request from datasets import load_dataset -from absa.config import DATA_DIR, RAW_DIR, FASTTEXT_MODEL_PATH +from absa.utils.config import DATA_DIR, RAW_DIR, FASTTEXT_MODEL_PATH def download_fasttext(): url = "https://dl.fbaipublicfiles.com/fasttext/supervised-models/lid.176.ftz" diff --git a/scripts/generate_notebooks.py b/scripts/generate_notebooks.py index bb066a1ac28fb64ddc6f9d9694f6f15f6e77eeba..4b4188b90b83007db3daf500e1ef9b6add5305bf 100644 --- a/scripts/generate_notebooks.py +++ b/scripts/generate_notebooks.py @@ -46,14 +46,14 @@ def create_notebook(filename: str, cells_content: list): def main(): colab_cells = [ ("# Google Colab Training Notebook\n\nThis notebook is intended to be run on Google Colab with a T4 GPU. It clones the repo, installs dependencies, and runs the training scripts.", "markdown"), - ("!git clone https://github.com/Aryanmishra-dev/Multilingual-Absa.git\n%cd Multilingual-Absa\n!pip install -r requirements.txt", "code"), + ("!git clone https://github.com/Aryanmishra-dev/Multilingual-Absa.git\n%cd Multilingual-Absa\n!pip install .", "code"), ("# Mount Google Drive to save models and MLflow logs persistently\nfrom google.colab import drive\ndrive.mount('/content/drive')", "code"), ("# Create symlinks or copy data if needed\n# Assuming data is in the repo for now\n!mkdir -p /content/drive/MyDrive/ABSA_models", "code"), - ("# Prepare dataset\n!PYTHONPATH=. python absa/data/hf_dataset.py", "code"), - ("# Run Aspect Extraction Training\n!PYTHONPATH=. python absa/models/train_aspect_extraction.py", "code"), - ("# Run Sentiment Classification Training\n!PYTHONPATH=. python absa/models/train_sentiment.py", "code"), - ("# Run Baseline as well\n!PYTHONPATH=. python absa/models/baseline.py", "code"), - ("# Cross-lingual Evaluation\n!PYTHONPATH=. python absa/evaluation/cross_lingual_eval.py", "code"), + ("# Prepare dataset\n!PYTHONPATH=src python -m absa.data.hf_dataset", "code"), + ("# Run Aspect Extraction Training\n!PYTHONPATH=src python -m absa.models.train_aspect_extraction", "code"), + ("# Run Sentiment Classification Training\n!PYTHONPATH=src python -m absa.models.train_sentiment", "code"), + ("# Run Baseline as well\n!PYTHONPATH=src python -m absa.models.baseline", "code"), + ("# Cross-lingual Evaluation\n!PYTHONPATH=src python -m absa.evaluation.cross_lingual_eval", "code"), ("# Copy models back to Drive\n!cp -r models/* /content/drive/MyDrive/ABSA_models/\n!cp -r mlflow /content/drive/MyDrive/ABSA_models/", "code") ] diff --git a/scripts/init_db.py b/scripts/init_db.py index 3b0d2ea6fc68a3b93799112c9b7e2bb7ecc82b22..ebf07ffb541981f75dfbb6534353c34fc5cbbb9c 100644 --- a/scripts/init_db.py +++ b/scripts/init_db.py @@ -5,7 +5,7 @@ from dotenv import load_dotenv import sys sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) -from app.schemas.db_models import Base +from api.schemas.db_models import Base def init_db(): load_dotenv() diff --git a/app/core/__init__.py b/src/absa/__init__.py similarity index 100% rename from app/core/__init__.py rename to src/absa/__init__.py diff --git a/app/middleware/__init__.py b/src/absa/data/__init__.py similarity index 100% rename from app/middleware/__init__.py rename to src/absa/data/__init__.py diff --git a/absa/data/augmentation.py b/src/absa/data/augmentation.py similarity index 100% rename from absa/data/augmentation.py rename to src/absa/data/augmentation.py diff --git a/absa/data/bio_tagger.py b/src/absa/data/bio_tagger.py similarity index 100% rename from absa/data/bio_tagger.py rename to src/absa/data/bio_tagger.py diff --git a/absa/data/dataset.py b/src/absa/data/dataset.py similarity index 100% rename from absa/data/dataset.py rename to src/absa/data/dataset.py diff --git a/absa/data/hf_dataset.py b/src/absa/data/hf_dataset.py similarity index 100% rename from absa/data/hf_dataset.py rename to src/absa/data/hf_dataset.py diff --git a/absa/data/hindi_loader.py b/src/absa/data/hindi_loader.py similarity index 100% rename from absa/data/hindi_loader.py rename to src/absa/data/hindi_loader.py diff --git a/absa/data/lang_detect.py b/src/absa/data/lang_detect.py similarity index 100% rename from absa/data/lang_detect.py rename to src/absa/data/lang_detect.py diff --git a/absa/data/preprocess.py b/src/absa/data/preprocess.py similarity index 100% rename from absa/data/preprocess.py rename to src/absa/data/preprocess.py diff --git a/absa/data/transliterate.py b/src/absa/data/transliterate.py similarity index 100% rename from absa/data/transliterate.py rename to src/absa/data/transliterate.py diff --git a/app/routes/__init__.py b/src/absa/evaluation/__init__.py similarity index 100% rename from app/routes/__init__.py rename to src/absa/evaluation/__init__.py diff --git a/absa/evaluation/benchmark_latency.py b/src/absa/evaluation/benchmark_latency.py similarity index 100% rename from absa/evaluation/benchmark_latency.py rename to src/absa/evaluation/benchmark_latency.py diff --git a/absa/evaluation/cross_lingual_eval.py b/src/absa/evaluation/cross_lingual_eval.py similarity index 100% rename from absa/evaluation/cross_lingual_eval.py rename to src/absa/evaluation/cross_lingual_eval.py diff --git a/absa/evaluation/final_eval.py b/src/absa/evaluation/final_eval.py similarity index 100% rename from absa/evaluation/final_eval.py rename to src/absa/evaluation/final_eval.py diff --git a/app/schemas/__init__.py b/src/absa/models/__init__.py similarity index 100% rename from app/schemas/__init__.py rename to src/absa/models/__init__.py diff --git a/absa/models/baseline.py b/src/absa/models/baseline.py similarity index 100% rename from absa/models/baseline.py rename to src/absa/models/baseline.py diff --git a/absa/models/export_onnx.py b/src/absa/models/export_onnx.py similarity index 100% rename from absa/models/export_onnx.py rename to src/absa/models/export_onnx.py diff --git a/absa/models/train_aspect_extraction.py b/src/absa/models/train_aspect_extraction.py similarity index 100% rename from absa/models/train_aspect_extraction.py rename to src/absa/models/train_aspect_extraction.py diff --git a/absa/models/train_joint_absa.py b/src/absa/models/train_joint_absa.py similarity index 100% rename from absa/models/train_joint_absa.py rename to src/absa/models/train_joint_absa.py diff --git a/absa/models/train_multilingual.py b/src/absa/models/train_multilingual.py similarity index 100% rename from absa/models/train_multilingual.py rename to src/absa/models/train_multilingual.py diff --git a/absa/models/train_qlora.py b/src/absa/models/train_qlora.py similarity index 100% rename from absa/models/train_qlora.py rename to src/absa/models/train_qlora.py diff --git a/absa/models/train_sentiment.py b/src/absa/models/train_sentiment.py similarity index 100% rename from absa/models/train_sentiment.py rename to src/absa/models/train_sentiment.py diff --git a/app/services/__init__.py b/src/absa/training/__init__.py similarity index 100% rename from app/services/__init__.py rename to src/absa/training/__init__.py diff --git a/absa/training/mlflow_utils.py b/src/absa/training/mlflow_utils.py similarity index 100% rename from absa/training/mlflow_utils.py rename to src/absa/training/mlflow_utils.py diff --git a/src/absa/utils/__init__.py b/src/absa/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/absa/utils/config.py b/src/absa/utils/config.py similarity index 92% rename from absa/utils/config.py rename to src/absa/utils/config.py index 2c29561f47bca30ea076fe01c9cd64fb463ad051..e340ca98d627893db716fc4ed10afc71114ae4d4 100644 --- a/absa/utils/config.py +++ b/src/absa/utils/config.py @@ -1,6 +1,6 @@ from pathlib import Path -ROOT_DIR = Path(__file__).parent.parent.parent +ROOT_DIR = Path(__file__).parent.parent.parent.parent DATA_DIR = ROOT_DIR / "data" RAW_DIR = DATA_DIR / "raw" PROCESSED_DIR = DATA_DIR / "processed" diff --git a/tests/api/test_api.py b/tests/api/test_api.py index 56bd48d81f320aedecb67cf2b7218864bf5e1cff..5a3084cc2883f748475ad5ea2fce958ba682cbe4 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -4,7 +4,7 @@ import os os.environ["DATABASE_URL"] = "sqlite:///./tests/fixtures/test.db" import unittest.mock as mock -from app.main import app +from api.main import app import json import io @@ -44,7 +44,7 @@ def test_batch_upload(): with TestClient(app) as client: csv_content = "text\nThe food was great\nTerrible service" files = {"file": ("test.csv", io.BytesIO(csv_content.encode("utf-8")), "text/csv")} - with mock.patch("app.routes.predict.process_batch.delay") as mock_delay: + with mock.patch("api.routes.predict.process_batch.delay") as mock_delay: response = client.post("/batch", files=files) assert response.status_code == 200 data = response.json() diff --git a/tests/unit/test_pipeline.py b/tests/unit/test_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..dfd846aab2672d7df38af0b9225551336b9384c3 --- /dev/null +++ b/tests/unit/test_pipeline.py @@ -0,0 +1,108 @@ +"""Unit tests for the rule-based ABSA pipeline aspect extraction + fallbacks.""" + +from __future__ import annotations + +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///./tests/fixtures/test.db") + +from api.services.absa_pipeline import pipeline + + +def _aspects(text: str) -> list[dict]: + result = pipeline.predict(text, "en") + return [a.model_dump() if hasattr(a, "model_dump") else dict(a) for a in result.aspects] + + +def _sentiment_of(aspects: list[dict], aspect: str) -> str | None: + for a in aspects: + if a["aspect"].lower() == aspect.lower(): + return a["sentiment"] + return None + + +def test_food_and_service_extracted_from_sentiment_words(): + aspects = _aspects("The food was great but the service was terrible.") + labels = [a["aspect"].lower() for a in aspects] + assert "food" in labels + assert "service" in labels + assert _sentiment_of(aspects, "food") == "positive" + assert _sentiment_of(aspects, "service") == "negative" + + +def test_noun_after_sentiment_word_extracted(): + aspects = _aspects("Great camera quality and a lovely experience overall.") + labels = [a["aspect"].lower() for a in aspects] + # "camera quality" is in the lexicon; "experience" must come from the fallback. + assert any("camera" in label or "experience" in label for label in labels) + + +def test_product_target_fallback(): + aspects = _aspects("I absolutely love this product.") + assert _sentiment_of(aspects, "product") == "positive" + + +def test_generic_overall_fallback_when_no_aspect_noun(): + aspects = _aspects("Absolutely terrible, do not recommend.") + assert aspects, "should always return at least one aspect" + assert _sentiment_of(aspects, "overall") == "negative" + + +def test_positive_bare_comment_returns_result(): + aspects = _aspects("Amazing!") + assert aspects, "should always return at least one aspect" + assert _sentiment_of(aspects, "overall") == "positive" + + +def test_neutral_text_returns_overall_neutral(): + aspects = _aspects("Hello there, just checking.") + assert aspects + assert _sentiment_of(aspects, "overall") == "neutral" + + +def test_devanagari_comment_gets_aspect_and_sentiment(): + aspects = _aspects("खाना बहुत अच्छा था।") + assert aspects, "Devanagari comment should still produce a result" + assert any(a["aspect"].lower() in ("खाना", "overall") for a in aspects) + assert _sentiment_of(aspects, "खाना") == "positive" + + +def test_empty_text_never_crashes(): + aspects = _aspects("") + assert aspects # generic fallback keeps the response non-empty + + +def test_lexicon_still_used_first(): + aspects = _aspects("The battery life is amazing but the screen is too dim.") + labels = [a["aspect"].lower() for a in aspects] + assert "battery life" in labels or "battery" in labels + assert _sentiment_of(aspects, "battery life") == "positive" + assert _sentiment_of(aspects, "screen") == "negative" + + +def test_purchase_target_with_strong_negation(): + aspects = _aspects("Worst purchase ever, do not buy.") + assert _sentiment_of(aspects, "purchase") == "negative" + + +def test_devanagari_mixed_clauses_split_on_lekin(): + aspects = _aspects("खाना बढ़िया था लेकिन सेवा खराब थी।") + assert _sentiment_of(aspects, "खाना") == "positive" + assert _sentiment_of(aspects, "सेवा") == "negative" + + +def test_hinglish_mixed_clauses_split_on_lekin(): + aspects = _aspects("The phone ka design badhiya hai lekin battery life kharab hai.") + assert _sentiment_of(aspects, "design") == "positive" + assert _sentiment_of(aspects, "battery life") == "negative" + + +def test_devanagari_word_never_split_inside(): + # Regression: "बढ़िया" used to be split by the "या" clause separator. + aspects = _aspects("खाना बहुत बढ़िया था।") + assert _sentiment_of(aspects, "खाना") == "positive" + + +def test_experience_positive(): + aspects = _aspects("Great experience overall, will order again.") + assert _sentiment_of(aspects, "experience") == "positive" diff --git a/tests/web/test_pages.py b/tests/web/test_pages.py deleted file mode 100644 index e9574f75415bcaad2897f9af23634836767f38c8..0000000000000000000000000000000000000000 --- a/tests/web/test_pages.py +++ /dev/null @@ -1,338 +0,0 @@ -""" -tests/web/test_pages.py — Phase 2 smoke tests. - -PURPOSE -------- -Verify that the new Jinja2 page infrastructure: - 1. Renders all three pages without raising an exception (HTTP 200). - 2. Returns HTML content (not JSON). - 3. Contains the expected page titles / identifiers. - 4. Does NOT break any existing JSON API endpoint. - 5. Serves static CSS from the /static mount. - -These tests are strictly additive — they do not modify or replace -any tests in tests/api/test_api.py. - -IMPORTANT: DATABASE_URL must be set before importing the app because -api/app/middleware/dependencies.py reads it at module-import time. -""" -import os - -# Must be set before any app import (same pattern as tests/api/test_api.py) -os.environ.setdefault("DATABASE_URL", "sqlite:///./tests/fixtures/test.db") - -from unittest import mock - -import pytest -from fastapi.testclient import TestClient - -from app.main import app - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _html_client() -> TestClient: - """Return a TestClient that triggers the lifespan (model loading).""" - return TestClient(app) - - -# --------------------------------------------------------------------------- -# Page rendering tests -# --------------------------------------------------------------------------- - -class TestPageRoutes: - """Smoke tests: every GET page route returns 200 HTML.""" - - def test_root_redirects_to_predict(self): - """GET / should return the Predict page (200, HTML).""" - with _html_client() as client: - response = client.get("/", follow_redirects=True) - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - - def test_predict_page_renders(self): - with _html_client() as client: - response = client.get("/predict") - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - assert "SentimentAI" in response.text - assert "Live Predictor" in response.text - - def test_batch_page_renders(self): - with _html_client() as client: - response = client.get("/batch") - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - assert "SentimentAI" in response.text - assert "Batch Analytics" in response.text - - def test_monitor_page_renders(self): - with _html_client() as client: - response = client.get("/monitor") - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - assert "SentimentAI" in response.text - assert "System Monitor" in response.text - - def test_sidebar_nav_items_present(self): - """All three nav links must appear in every page.""" - with _html_client() as client: - for path in ("/predict", "/batch", "/monitor"): - response = client.get(path) - assert response.status_code == 200 - # Check all nav labels are present - assert "Predictor" in response.text - assert "Batch Analytics" in response.text - assert "System Health" in response.text - - def test_predict_active_state(self): - """/predict page must mark Predictor nav item as active.""" - with _html_client() as client: - response = client.get("/predict") - assert "nav-item-active" in response.text - # The active item must contain the predictor icon - assert "psychology" in response.text - - def test_base_template_includes_htmx(self): - """HTMX CDN script must be present in every page.""" - with _html_client() as client: - response = client.get("/predict") - assert "htmx.org" in response.text - - def test_base_template_includes_alpinejs(self): - """Alpine.js CDN script must be present in every page.""" - with _html_client() as client: - response = client.get("/predict") - assert "alpinejs" in response.text - - def test_base_template_includes_tailwind(self): - """Tailwind CDN script must be present in every page.""" - with _html_client() as client: - response = client.get("/predict") - assert "cdn.tailwindcss.com" in response.text - - -# --------------------------------------------------------------------------- -# Static file tests -# --------------------------------------------------------------------------- - -class TestStaticFiles: - """Verify the /static mount serves files correctly.""" - - def test_css_file_served(self): - with _html_client() as client: - response = client.get("/static/css/app.css") - assert response.status_code == 200 - assert "text/css" in response.headers["content-type"] - # Spot-check for key design system classes - assert "badge-positive" in response.text - assert "btn-primary" in response.text - - def test_static_missing_file_returns_404(self): - with _html_client() as client: - response = client.get("/static/does-not-exist.css") - assert response.status_code == 404 - - -# --------------------------------------------------------------------------- -# Regression tests — existing API must be unaffected -# --------------------------------------------------------------------------- - -class TestExistingAPIUnchanged: - """ - Re-run the core API assertions to prove Phase 2 changes introduced - zero regressions. These mirror tests/api/test_api.py in spirit. - """ -class TestPredictFragment: - """Verify the new HTMX predict endpoint works exactly like the JSON one.""" - - def test_predict_fragment_english(self): - with _html_client() as client: - response = client.post( - "/predict/fragment", - data={"text": "The food was great but service was slow.", "language": "en"}, - ) - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - assert "Detected Aspects" in response.text - # the response might say "No aspects detected" or show the list, but both are valid HTML - assert "food" in response.text # text should be in the annotated container - - def test_predict_fragment_auto(self): - with _html_client() as client: - response = client.post( - "/predict/fragment", - data={"text": "The food was great but service was slow.", "language": "auto"}, - ) - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - assert "EN" in response.text or "English" in response.text or "en" in response.text - - def test_predict_fragment_empty_text(self): - with _html_client() as client: - # Form submission missing 'text' should trigger FastAPI 422 - response = client.post("/predict/fragment", data={"language": "en"}) - # Note: Depending on FastAPI validation, missing Form field might be 422 - assert response.status_code == 422 - def test_health_endpoint_still_returns_json(self): - with _html_client() as client: - response = client.get("/health") - assert response.status_code == 200 - data = response.json() - assert data["status"] == "ok" - # Verify content-type is JSON (not HTML) - assert "application/json" in response.headers["content-type"] - - def test_info_endpoint_still_returns_json(self): - with _html_client() as client: - response = client.get("/info") - assert response.status_code == 200 - data = response.json() - assert "model_name" in data - assert "supported_languages" in data - - def test_predict_endpoint_still_returns_json(self): - """POST /predict must still return PredictionResponse JSON.""" - with _html_client() as client: - response = client.post( - "/predict", - json={"text": "The sound quality is excellent.", "language": "en"}, - ) - assert response.status_code == 200 - data = response.json() - # Schema check — all required fields must be present - assert "text" in data - assert "language" in data - assert "detected_language" in data - assert "aspects" in data - assert "processing_time_ms" in data - # Content-type must be JSON - assert "application/json" in response.headers["content-type"] - - def test_openapi_schema_page_routes_hidden(self): - """Page GET routes must NOT appear in the OpenAPI schema.""" - with _html_client() as client: - response = client.get("/openapi.json") - assert response.status_code == 200 - schema = response.json() - paths = schema.get("paths", {}) - # None of the page routes should be in the schema - assert "/predict" not in paths or all( - method == "post" for method in paths.get("/predict", {}) - ), "GET /predict should not appear in OpenAPI schema" - assert "/" not in paths, "GET / should not appear in OpenAPI schema" - assert "/batch" not in paths or all( - method == "post" for method in paths.get("/batch", {}) - ), "GET /batch should not appear in OpenAPI schema" - assert "/monitor" not in paths, "GET /monitor should not appear in OpenAPI schema" - -class TestBatchFragments: - """Verify Phase 4 Batch Analytics HTMX endpoints.""" - - def test_batch_fragment_upload(self): - with _html_client() as client: - files = {"file": ("test.csv", b"text\nThis is great\nThis is bad", "text/csv")} - with mock.patch("app.routes.predict.process_batch.delay"): - response = client.post("/batch/fragment", files=files) - - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - assert "Batch Job:" in response.text - assert "queued" in response.text.lower() or "processing" in response.text.lower() or "completed" in response.text.lower() - - def test_batch_fragment_invalid_file(self): - with _html_client() as client: - files = {"file": ("test.txt", b"text\nThis is great", "text/plain")} - with mock.patch("app.routes.predict.process_batch.delay"): - response = client.post("/batch/fragment", files=files) - - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - assert "Only CSV files are allowed" in response.text or "error" in response.text.lower() - - def test_batch_progress_polling(self): - # 1. upload file to get job_id - with _html_client() as client: - files = {"file": ("test.csv", b"text\nHello", "text/csv")} - with mock.patch("app.routes.predict.process_batch.delay"): - response1 = client.post("/batch/fragment", files=files) - import re - match = re.search(r'([a-f0-9-]+)', response1.text) - assert match, "Could not find job_id in HTML" - job_id = match.group(1) - - # 2. poll progress - response2 = client.get(f"/batch/progress/{job_id}") - assert response2.status_code == 200 - assert "text/html" in response2.headers["content-type"] - assert job_id in response2.text - - def test_download_endpoint_exists(self): - with _html_client() as client: - response = client.get("/results/download/fake-job-id") - assert response.status_code == 404 - - def test_batch_charts_endpoint_handles_missing_file(self): - with _html_client() as client: - response = client.get("/batch/charts/fake-job-id") - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - assert "CSV not found" in response.text - - def test_batch_charts_endpoint_valid_file(self, tmp_path): - import pandas as pd - from pathlib import Path - - # Create a mock CSV for a fake job - job_id = "test-job-charts" - test_file = Path(f"data/results/{job_id}.csv") - test_file.parent.mkdir(parents=True, exist_ok=True) - - df = pd.DataFrame({ - "text": ["hello", "world"], - "language": ["en", "hi"], - "aspect": ["food", "service"], - "sentiment": ["positive", "negative"] - }) - df.to_csv(test_file, index=False) - - try: - with _html_client() as client: - response = client.get(f"/batch/charts/{job_id}") - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - assert "chart.js" in response.text.lower() - assert "languageChart" in response.text - finally: - if test_file.exists(): - test_file.unlink() - -class TestMonitorFragments: - """Verify Phase 5 System Monitor HTMX endpoints.""" - - def test_monitor_health_fragment(self): - with _html_client() as client: - response = client.get("/monitor/health-partial") - - assert response.status_code == 200 - assert "text/html" in response.headers["content-type"] - assert "Current state:" in response.text - assert "Healthy" in response.text or "Degraded" in response.text - - def test_monitor_page_initial_render(self): - with _html_client() as client: - response = client.get("/monitor") - - assert response.status_code == 200 - assert "System Monitor" in response.text - assert "hx-get=\"/monitor/health-partial\"" in response.text - assert "hx-trigger=\"every 30s\"" in response.text - assert "Current state:" in response.text - - def test_monitor_fragment_error_handling(self): - # We can't easily mock the error in the test suite without patching, - # but we can verify the template renders cleanly and doesn't 500 - # if the health_check fails by manually rendering it. - pass diff --git a/tests/web/test_streamlit_app.py b/tests/web/test_streamlit_app.py new file mode 100644 index 0000000000000000000000000000000000000000..34c7ec0928cece96ee016b36adedff795c814f18 --- /dev/null +++ b/tests/web/test_streamlit_app.py @@ -0,0 +1,120 @@ +"""Smoke tests for the Streamlit dashboard pages via streamlit AppTest. + +These verify each page script runs without raising, and that key UI elements +render. The API base URL is pointed at a dead port so health/network calls fail +fast and deterministically (pages degrade gracefully). +""" + +from __future__ import annotations + +import os +import sys + +os.environ["API_BASE_URL"] = "http://127.0.0.1:1" + +import pytest +from streamlit.testing.v1 import AppTest + +ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +# Real `streamlit run` adds the app dir to sys.path; AppTest does not, so mimic it. +APP_DIR = os.path.join(ROOT, "frontend") +if APP_DIR not in sys.path: + sys.path.insert(0, APP_DIR) + +APP_FILES = { + "home": "frontend/Home.py", + "predict": "frontend/views/predict.py", + "overview": "frontend/views/admin/overview.py", + "batch": "frontend/views/admin/batch.py", + "monitor": "frontend/views/admin/monitor.py", +} + + +@pytest.mark.parametrize("page", list(APP_FILES.keys())) +def test_page_runs_without_exception(page: str): + at = AppTest.from_file(os.path.join(ROOT, APP_FILES[page])) + at.run(timeout=30) + assert not at.exception, f"{page} raised: {at.exception}" + + +# ── Public user view (Sentiment Analyzer) ───────────────────────────────────── + + +def test_predict_is_clean_input_to_results_view(): + at = AppTest.from_file(os.path.join(ROOT, APP_FILES["predict"])) + at.run(timeout=30) + rendered = " ".join(str(m.value) for m in at.markdown) + assert "Sentiment Analyzer" in rendered + assert len(at.text_area) >= 1 + assert any(b.label == "Analyze Sentiment" for b in at.button) + + +def test_predict_analyze_button_disabled_for_empty_text(): + at = AppTest.from_file(os.path.join(ROOT, APP_FILES["predict"])) + at.run(timeout=30) + analyze = next(b for b in at.button if b.label == "Analyze Sentiment") + assert analyze.disabled + + +def test_home_navigation_lands_on_analyzer(): + at = AppTest.from_file(os.path.join(ROOT, APP_FILES["home"])) + at.run(timeout=30) + assert not at.exception + rendered = " ".join(str(m.value) for m in at.markdown) + assert "Sentiment Analyzer" in rendered + + +# ── Admin views ─────────────────────────────────────────────────────────────── + + +def test_admin_overview_shows_status_metrics(): + at = AppTest.from_file(os.path.join(ROOT, APP_FILES["overview"])) + at.run(timeout=30) + labels = [m.label for m in at.metric] + assert "API Status" in labels + assert "Model" in labels + + +def test_admin_overview_lists_features(): + at = AppTest.from_file(os.path.join(ROOT, APP_FILES["overview"])) + at.run(timeout=30) + rendered = " ".join(str(m.value) for m in at.markdown) + assert "Batch Analytics" in rendered + assert "System Monitor" in rendered + + +def test_admin_batch_page_has_csv_uploader(): + at = AppTest.from_file(os.path.join(ROOT, APP_FILES["batch"])) + at.run(timeout=30) + assert len(at.get("file_uploader")) >= 1 + + +def test_admin_monitor_handles_api_unreachable(): + at = AppTest.from_file(os.path.join(ROOT, APP_FILES["monitor"])) + at.run(timeout=30) + assert not at.exception + rendered = " ".join(str(m.value) for m in at.markdown) + errors = " ".join(str(e.value) for e in at.error) + assert "unreachable" in (rendered + errors).lower() + + +def test_admin_locked_without_password(monkeypatch): + monkeypatch.setenv("ADMIN_PASSWORD", "secret") + at = AppTest.from_file(os.path.join(ROOT, APP_FILES["overview"])) + at.run(timeout=30) + assert not at.exception + labels = [m.label for m in at.metric] + assert "API Status" not in labels + assert any(t.label == "Admin password" for t in at.text_input) + + +def test_admin_unlocked_with_correct_password(monkeypatch): + monkeypatch.setenv("ADMIN_PASSWORD", "secret") + at = AppTest.from_file(os.path.join(ROOT, APP_FILES["overview"])) + at.run(timeout=30) + at.text_input(key="admin_password").set_value("secret") + at.button(key="admin_unlock").click().run(timeout=30) + assert not at.exception + labels = [m.label for m in at.metric] + assert "API Status" in labels diff --git a/tests/web/test_streamlit_client.py b/tests/web/test_streamlit_client.py new file mode 100644 index 0000000000000000000000000000000000000000..0985d44ecbd9ce26de853481b1b71c99cb150b0b --- /dev/null +++ b/tests/web/test_streamlit_client.py @@ -0,0 +1,108 @@ +"""Unit tests for the Streamlit API client (httpx MockTransport, no network).""" + +from __future__ import annotations + +import httpx +import pytest + +from frontend.absa_client import APIClient + + +def _client(handler) -> APIClient: + return APIClient(base_url="http://testserver", transport=httpx.MockTransport(handler)) + + +def _json_handler(payload, status: int = 200): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(status, json=payload, request=request) + + return handler + + +def test_health_returns_json(): + client = _client(_json_handler({"status": "ok", "model": "loaded"})) + assert client.get_health() == {"status": "ok", "model": "loaded"} + + +def test_info_returns_json(): + client = _client(_json_handler({"model_name": "xlm-roberta-base-absa"})) + assert client.get_info()["model_name"] == "xlm-roberta-base-absa" + + +def test_predict_payload_language_auto_sends_null(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["json"] = request.content + return httpx.Response(200, json={"aspects": []}, request=request) + + client = _client(handler) + client.predict("Great phone", "auto") + import json + + assert json.loads(captured["json"]) == {"text": "Great phone", "language": None} + + +def test_predict_payload_explicit_language(): + captured = {} + + def handler(request: httpx.Request) -> httpx.Response: + captured["json"] = request.content + return httpx.Response(200, json={"aspects": []}, request=request) + + client = _client(handler) + client.predict("बढ़िया फोन", "hi") + import json + + assert json.loads(captured["json"]) == {"text": "बढ़िया फोन", "language": "hi"} + + +def test_upload_batch_sends_csv_file(): + captured = {} + + class FakeFile: + name = "reviews.csv" + getvalue = lambda self: b"text\nGreat\n" # noqa: E731 + + def handler(request: httpx.Request) -> httpx.Response: + captured["content_type"] = request.headers.get("content-type", "") + return httpx.Response(200, json={"job_id": "abc-123", "status": "queued"}, request=request) + + client = _client(handler) + result = client.upload_batch(FakeFile()) + assert result["job_id"] == "abc-123" + assert "multipart/form-data" in captured["content_type"] + + +def test_get_batch_status_parses(): + client = _client(_json_handler({"job_id": "x", "status": "completed", "processed": 5})) + status = client.get_batch_status("x") + assert status["status"] == "completed" + assert status["processed"] == 5 + + +def test_download_result_returns_bytes(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, content=b"text,sentiment\nok,positive\n", request=request) + + client = _client(handler) + assert client.download_result("abc") == b"text,sentiment\nok,positive\n" + + +def test_http_error_returns_none(): + client = _client(_json_handler({"detail": "boom"}, status=500)) + assert client.get_health() is None + + +def test_network_error_returns_none(): + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("refused", request=request) + + client = _client(handler) + assert client.get_health() is None + + +@pytest.mark.parametrize("status", ["queued", "processing", "completed", "failed"]) +def test_status_passthrough(status: str): + client = _client(_json_handler({"status": status})) + assert client.get_batch_status("x")["status"] == status