Spaces:
Runtime error
Runtime error
Aryan Mishra commited on
Commit ·
927450f
1
Parent(s): ae0eb95
feat: updates to frontend dashboard and api services
Browse files- api/main.py +13 -7
- api/middleware/dependencies.py +7 -1
- api/services/absa_pipeline.py +299 -140
- config/docker/Dockerfile.api +3 -1
- dashboard/dist/_redirects +1 -0
- dashboard/dist/assets/index-DGl1EjcM.css +1 -0
- dashboard/dist/assets/index-w96O4ZPi.js +0 -0
- dashboard/dist/index.html +23 -0
- dashboard/index.html +12 -3
- dashboard/package-lock.json +0 -0
- dashboard/src/App.jsx +1 -2
- dashboard/src/components/Layout.jsx +96 -14
- dashboard/src/components/LivePredictor.jsx +262 -154
- dashboard/src/components/Navbar.jsx +0 -120
- dashboard/src/components/Sidebar.jsx +132 -0
- dashboard/src/index.css +138 -5
- dashboard/src/pages/Analytics.jsx +192 -114
- dashboard/src/pages/Monitor.jsx +183 -74
- dashboard/src/pages/Predict.jsx +6 -7
- dashboard/tailwind.config.js +118 -1
- requirements.txt +1 -0
api/main.py
CHANGED
|
@@ -4,26 +4,23 @@ from dotenv import load_dotenv
|
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from prometheus_fastapi_instrumentator import Instrumentator
|
| 6 |
|
|
|
|
|
|
|
| 7 |
from api.routes import predict, results
|
| 8 |
from api.middleware.metrics import instrumentator
|
| 9 |
from api.services.absa_pipeline import pipeline
|
| 10 |
from api.models.db_models import Base
|
| 11 |
from api.middleware.dependencies import engine
|
| 12 |
|
| 13 |
-
# Create DB tables (if using simple SQLite, otherwise use Alembic)
|
| 14 |
-
Base.metadata.create_all(bind=engine)
|
| 15 |
-
|
| 16 |
-
load_dotenv()
|
| 17 |
-
|
| 18 |
@asynccontextmanager
|
| 19 |
async def lifespan(app: FastAPI):
|
| 20 |
# Startup
|
| 21 |
print("Initializing Database tables...")
|
| 22 |
Base.metadata.create_all(bind=engine)
|
| 23 |
-
|
| 24 |
print("Loading Models...")
|
| 25 |
pipeline.load_models()
|
| 26 |
-
|
| 27 |
yield
|
| 28 |
# Shutdown
|
| 29 |
print("Shutting down...")
|
|
@@ -35,6 +32,15 @@ app = FastAPI(
|
|
| 35 |
lifespan=lifespan
|
| 36 |
)
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
app.include_router(predict.router, tags=["Predict"])
|
| 39 |
app.include_router(results.router, tags=["System"])
|
| 40 |
|
|
|
|
| 4 |
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
from prometheus_fastapi_instrumentator import Instrumentator
|
| 6 |
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
from api.routes import predict, results
|
| 10 |
from api.middleware.metrics import instrumentator
|
| 11 |
from api.services.absa_pipeline import pipeline
|
| 12 |
from api.models.db_models import Base
|
| 13 |
from api.middleware.dependencies import engine
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
@asynccontextmanager
|
| 16 |
async def lifespan(app: FastAPI):
|
| 17 |
# Startup
|
| 18 |
print("Initializing Database tables...")
|
| 19 |
Base.metadata.create_all(bind=engine)
|
| 20 |
+
|
| 21 |
print("Loading Models...")
|
| 22 |
pipeline.load_models()
|
| 23 |
+
|
| 24 |
yield
|
| 25 |
# Shutdown
|
| 26 |
print("Shutting down...")
|
|
|
|
| 32 |
lifespan=lifespan
|
| 33 |
)
|
| 34 |
|
| 35 |
+
# Allow the dashboard (and any origin in dev) to call the API
|
| 36 |
+
app.add_middleware(
|
| 37 |
+
CORSMiddleware,
|
| 38 |
+
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000", "*"],
|
| 39 |
+
allow_credentials=True,
|
| 40 |
+
allow_methods=["*"],
|
| 41 |
+
allow_headers=["*"],
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
app.include_router(predict.router, tags=["Predict"])
|
| 45 |
app.include_router(results.router, tags=["System"])
|
| 46 |
|
api/middleware/dependencies.py
CHANGED
|
@@ -7,7 +7,13 @@ load_dotenv()
|
|
| 7 |
|
| 8 |
DATABASE_URL = os.getenv("DATABASE_URL")
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 12 |
|
| 13 |
def get_db():
|
|
|
|
| 7 |
|
| 8 |
DATABASE_URL = os.getenv("DATABASE_URL")
|
| 9 |
|
| 10 |
+
if not DATABASE_URL:
|
| 11 |
+
raise RuntimeError(
|
| 12 |
+
"DATABASE_URL environment variable is not set. "
|
| 13 |
+
"Please set it in your .env file or environment."
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
engine = create_engine(DATABASE_URL, pool_pre_ping=True)
|
| 17 |
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 18 |
|
| 19 |
def get_db():
|
api/services/absa_pipeline.py
CHANGED
|
@@ -1,18 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
-
|
| 3 |
import time
|
|
|
|
|
|
|
|
|
|
| 4 |
import numpy as np
|
| 5 |
-
|
| 6 |
from api.models.schemas import PredictionResponse, AspectSentiment
|
| 7 |
from api.services.lang_service import lang_service
|
| 8 |
|
|
|
|
| 9 |
try:
|
| 10 |
-
from optimum.onnxruntime import
|
|
|
|
|
|
|
|
|
|
| 11 |
from transformers import AutoTokenizer
|
| 12 |
-
from huggingface_hub import hf_hub_download, snapshot_download
|
| 13 |
OPTIMUM_AVAILABLE = True
|
| 14 |
except ImportError:
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
class ABSAPipeline:
|
| 18 |
def __init__(self):
|
|
@@ -20,158 +168,169 @@ class ABSAPipeline:
|
|
| 20 |
self.aspect_model = None
|
| 21 |
self.sentiment_model = None
|
| 22 |
self.is_loaded = False
|
| 23 |
-
|
| 24 |
-
|
| 25 |
self.id2label = {0: "O", 1: "B-ASP", 2: "I-ASP"}
|
| 26 |
self.sentiment_id2label = {0: "positive", 1: "negative", 2: "neutral", 3: "conflict"}
|
| 27 |
|
| 28 |
def load_models(self):
|
| 29 |
-
"""
|
| 30 |
-
|
| 31 |
-
Attempts to load quantized INT8 ONNX models for token classification
|
| 32 |
-
and sequence classification. If local paths are missing and MODEL_SOURCE
|
| 33 |
-
is huggingface_hub, it downloads them from the Hub.
|
| 34 |
-
"""
|
| 35 |
-
if not OPTIMUM_AVAILABLE:
|
| 36 |
-
print("Optimum not available. ABSA Pipeline will use dummy responses.")
|
| 37 |
-
self.is_loaded = True
|
| 38 |
-
return
|
| 39 |
-
|
| 40 |
model_path_base = Path(os.getenv("MODEL_PATH", "models/onnx"))
|
| 41 |
-
hf_repo_id = os.getenv("HF_MODEL_REPO", "
|
| 42 |
-
use_hub = os.getenv("MODEL_SOURCE", "local") == "huggingface_hub"
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
def predict(self, text: str, requested_lang: str = None) -> PredictionResponse:
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
Args:
|
| 71 |
-
text: Raw review text in any supported language.
|
| 72 |
-
requested_lang: Optional language code to override auto-detection.
|
| 73 |
-
|
| 74 |
-
Returns:
|
| 75 |
-
PredictionResponse containing detected language, processing time,
|
| 76 |
-
and a list of extracted aspects with their sentiments and confidences.
|
| 77 |
-
|
| 78 |
-
Raises:
|
| 79 |
-
ValueError: If text is empty or exceeds length limits (handled downstream).
|
| 80 |
-
"""
|
| 81 |
-
start_time = time.time()
|
| 82 |
-
|
| 83 |
detected_lang = lang_service.detect_language(text)
|
| 84 |
-
actual_lang = requested_lang
|
| 85 |
-
|
| 86 |
-
if
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
inputs = self.tokenizer(text, return_tensors="pt", truncation=True, max_length=128)
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
predictions = np.argmax(logits, axis=1)
|
| 102 |
-
|
| 103 |
tokens = self.tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
# Very basic BIO decoding logic
|
| 110 |
-
for idx, (token, pred) in enumerate(zip(tokens, predictions)):
|
| 111 |
-
if token in [self.tokenizer.cls_token, self.tokenizer.sep_token, self.tokenizer.pad_token]:
|
| 112 |
continue
|
| 113 |
-
|
| 114 |
-
label = self.id2label.get(pred, "O")
|
| 115 |
if label == "B-ASP":
|
| 116 |
-
if
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
current_aspect.append(token)
|
| 122 |
else:
|
| 123 |
-
if
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
# 2. Sentiment Classification per aspect
|
| 131 |
results = []
|
| 132 |
-
for
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
# softmax
|
| 140 |
-
exp_logits = np.exp(sent_logits - np.max(sent_logits))
|
| 141 |
-
probs = exp_logits / exp_logits.sum()
|
| 142 |
-
|
| 143 |
-
pred_class = np.argmax(probs)
|
| 144 |
-
confidence = float(probs[pred_class])
|
| 145 |
-
sentiment = self.sentiment_id2label.get(pred_class, "neutral")
|
| 146 |
-
|
| 147 |
results.append(AspectSentiment(
|
| 148 |
-
aspect=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
sentiment=sentiment,
|
| 150 |
confidence=confidence,
|
| 151 |
-
start=
|
| 152 |
-
end=
|
| 153 |
))
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
return
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
return [self.predict(text) for text in texts]
|
| 176 |
|
| 177 |
pipeline = ABSAPipeline()
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
ABSA Pipeline — Zero-download, production-ready fallback.
|
| 3 |
+
|
| 4 |
+
Strategy:
|
| 5 |
+
1. Try loading custom ONNX models (if present in models/onnx/).
|
| 6 |
+
2. Fall back to a fast, pure-Python rule-based engine:
|
| 7 |
+
- Aspect extraction: keyword matching against a curated product-review lexicon.
|
| 8 |
+
- Sentiment classification: context-window scoring with a 200-word
|
| 9 |
+
positive/negative/negation dictionary — works for EN and HI.
|
| 10 |
+
No external downloads. Starts instantly.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
import os
|
| 14 |
+
import re
|
| 15 |
import time
|
| 16 |
+
import threading
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import List, Tuple
|
| 19 |
import numpy as np
|
| 20 |
+
|
| 21 |
from api.models.schemas import PredictionResponse, AspectSentiment
|
| 22 |
from api.services.lang_service import lang_service
|
| 23 |
|
| 24 |
+
# ── Optional heavy imports (ONNX custom models) ───────────────────────────────
|
| 25 |
try:
|
| 26 |
+
from optimum.onnxruntime import (
|
| 27 |
+
ORTModelForTokenClassification,
|
| 28 |
+
ORTModelForSequenceClassification,
|
| 29 |
+
)
|
| 30 |
from transformers import AutoTokenizer
|
|
|
|
| 31 |
OPTIMUM_AVAILABLE = True
|
| 32 |
except ImportError:
|
| 33 |
+
try:
|
| 34 |
+
from transformers import AutoTokenizer
|
| 35 |
+
OPTIMUM_AVAILABLE = False
|
| 36 |
+
except ImportError:
|
| 37 |
+
OPTIMUM_AVAILABLE = False
|
| 38 |
+
|
| 39 |
+
# ── Aspect keyword lexicon ────────────────────────────────────────────────────
|
| 40 |
+
# Ordered longest-first so multi-word matches win over single words.
|
| 41 |
+
ASPECT_PHRASES: List[str] = sorted([
|
| 42 |
+
# Audio
|
| 43 |
+
"sound quality", "audio quality", "bass response", "bass", "treble",
|
| 44 |
+
"noise cancellation", "active noise cancellation", "anc", "passive noise isolation",
|
| 45 |
+
"microphone quality", "microphone", "mic", "speakers", "speaker",
|
| 46 |
+
"audio", "sound", "volume",
|
| 47 |
+
# Battery / Power
|
| 48 |
+
"battery life", "battery performance", "charging speed", "fast charging",
|
| 49 |
+
"wireless charging", "charging case", "battery", "charging", "power",
|
| 50 |
+
# Design / Build
|
| 51 |
+
"build quality", "build", "design", "comfort", "fit and finish",
|
| 52 |
+
"ergonomics", "weight", "size", "material", "finish", "durability",
|
| 53 |
+
# Connectivity
|
| 54 |
+
"bluetooth connectivity", "bluetooth", "wifi", "wi-fi", "connectivity",
|
| 55 |
+
"wireless connection", "pairing", "latency", "lag",
|
| 56 |
+
# Display
|
| 57 |
+
"display quality", "screen quality", "display", "screen", "resolution",
|
| 58 |
+
"brightness", "touchscreen",
|
| 59 |
+
# Camera
|
| 60 |
+
"camera quality", "image quality", "video quality", "camera", "lens", "photo",
|
| 61 |
+
# Performance
|
| 62 |
+
"performance", "processing speed", "speed", "processor", "ram", "memory",
|
| 63 |
+
"loading time",
|
| 64 |
+
# Software / Features
|
| 65 |
+
"user interface", "software", "app", "features", "controls", "buttons",
|
| 66 |
+
"touch controls",
|
| 67 |
+
# Value
|
| 68 |
+
"value for money", "price", "cost", "value",
|
| 69 |
+
# Support / Delivery
|
| 70 |
+
"customer service", "customer support", "warranty", "delivery", "packaging",
|
| 71 |
+
# General
|
| 72 |
+
"quality", "reliability", "overall experience",
|
| 73 |
+
], key=len, reverse=True)
|
| 74 |
+
|
| 75 |
+
# ── Sentiment lexicon ─────────────────────────────────────────────────────────
|
| 76 |
+
POSITIVE_WORDS = {
|
| 77 |
+
"excellent", "great", "amazing", "outstanding", "superb", "fantastic",
|
| 78 |
+
"wonderful", "perfect", "impressive", "exceptional", "brilliant", "splendid",
|
| 79 |
+
"good", "nice", "solid", "strong", "reliable", "consistent", "smooth",
|
| 80 |
+
"clear", "crisp", "rich", "deep", "powerful", "comfortable", "enjoyable",
|
| 81 |
+
"satisfied", "happy", "love", "loved", "like", "loved", "commendable",
|
| 82 |
+
"recommend", "recommended", "worth", "affordable", "value", "effective",
|
| 83 |
+
"efficient", "accurate", "precise", "sharp", "vibrant", "vivid",
|
| 84 |
+
"fast", "quick", "snappy", "instant", "stable", "durable", "sturdy",
|
| 85 |
+
"premium", "high-quality", "high quality", "top-notch", "top notch",
|
| 86 |
+
"long", "lasting", "enduring", "impressive", "praise", "appreciate",
|
| 87 |
+
# Hindi positive (transliterated)
|
| 88 |
+
"badhiya", "achha", "accha", "shandar", "zabardast", "mast",
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
NEGATIVE_WORDS = {
|
| 92 |
+
"bad", "poor", "terrible", "awful", "horrible", "dreadful", "atrocious",
|
| 93 |
+
"disappointing", "disappointed", "mediocre", "weak", "subpar", "inferior",
|
| 94 |
+
"cheap", "flimsy", "fragile", "unreliable", "inconsistent", "unstable",
|
| 95 |
+
"slow", "sluggish", "laggy", "lag", "delay", "delayed", "glitchy", "buggy",
|
| 96 |
+
"noisy", "distorted", "muffled", "blurry", "dim", "dull", "flat",
|
| 97 |
+
"short", "low", "limited", "lacking", "missing", "absent",
|
| 98 |
+
"expensive", "overpriced", "pricey", "costly",
|
| 99 |
+
"uncomfortable", "annoying", "frustrating", "irritating",
|
| 100 |
+
"failed", "failure", "broken", "defective", "faulty",
|
| 101 |
+
"average", "ordinary", "basic", "minimal",
|
| 102 |
+
# Hindi negative (transliterated)
|
| 103 |
+
"kharab", "bekaar", "bura", "ganda", "faltu",
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
NEGATION_WORDS = {
|
| 107 |
+
"not", "no", "never", "neither", "nor", "barely", "hardly", "scarcely",
|
| 108 |
+
"doesn't", "don't", "didn't", "isn't", "aren't", "wasn't", "weren't",
|
| 109 |
+
"without", "lack", "lacks", "lacking", "failed", "fails",
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
INTENSIFIERS = {
|
| 113 |
+
"very", "extremely", "incredibly", "absolutely", "truly", "really",
|
| 114 |
+
"highly", "remarkably", "exceptionally", "super", "too",
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _score_sentence(sentence: str) -> Tuple[float, float]:
|
| 119 |
+
"""
|
| 120 |
+
Return (positive_score, negative_score) for a sentence.
|
| 121 |
+
Handles negation (3-word window) and intensifiers.
|
| 122 |
+
"""
|
| 123 |
+
words = re.findall(r"\b[\w'-]+\b", sentence.lower())
|
| 124 |
+
pos, neg = 0.0, 0.0
|
| 125 |
+
i = 0
|
| 126 |
+
while i < len(words):
|
| 127 |
+
w = words[i]
|
| 128 |
+
# Look-back for negation in previous 3 words
|
| 129 |
+
context = words[max(0, i - 3):i]
|
| 130 |
+
negated = any(n in context for n in NEGATION_WORDS)
|
| 131 |
+
# Look-back for intensifier
|
| 132 |
+
intensity = 1.5 if any(t in context for t in INTENSIFIERS) else 1.0
|
| 133 |
+
|
| 134 |
+
if w in POSITIVE_WORDS:
|
| 135 |
+
if negated:
|
| 136 |
+
neg += 1.0 * intensity
|
| 137 |
+
else:
|
| 138 |
+
pos += 1.0 * intensity
|
| 139 |
+
elif w in NEGATIVE_WORDS:
|
| 140 |
+
if negated:
|
| 141 |
+
pos += 0.5 * intensity
|
| 142 |
+
else:
|
| 143 |
+
neg += 1.0 * intensity
|
| 144 |
+
i += 1
|
| 145 |
+
return pos, neg
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _score_to_label(pos: float, neg: float) -> Tuple[str, float]:
|
| 149 |
+
"""Convert raw scores to (label, confidence)."""
|
| 150 |
+
total = pos + neg
|
| 151 |
+
if total == 0:
|
| 152 |
+
return "neutral", 0.60
|
| 153 |
+
ratio = pos / total
|
| 154 |
+
if ratio > 0.60:
|
| 155 |
+
confidence = min(0.55 + ratio * 0.40, 0.97)
|
| 156 |
+
return "positive", round(confidence, 3)
|
| 157 |
+
elif ratio < 0.40:
|
| 158 |
+
confidence = min(0.55 + (1 - ratio) * 0.40, 0.97)
|
| 159 |
+
return "negative", round(confidence, 3)
|
| 160 |
+
return "neutral", round(0.50 + abs(ratio - 0.5) * 0.6, 3)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ── Main pipeline class ───────────────────────────────────────────────────────
|
| 164 |
|
| 165 |
class ABSAPipeline:
|
| 166 |
def __init__(self):
|
|
|
|
| 168 |
self.aspect_model = None
|
| 169 |
self.sentiment_model = None
|
| 170 |
self.is_loaded = False
|
| 171 |
+
self._lock = threading.Lock()
|
| 172 |
+
|
| 173 |
self.id2label = {0: "O", 1: "B-ASP", 2: "I-ASP"}
|
| 174 |
self.sentiment_id2label = {0: "positive", 1: "negative", 2: "neutral", 3: "conflict"}
|
| 175 |
|
| 176 |
def load_models(self):
|
| 177 |
+
"""Try to load custom ONNX models; mark ready immediately (no downloads)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
model_path_base = Path(os.getenv("MODEL_PATH", "models/onnx"))
|
| 179 |
+
hf_repo_id = os.getenv("HF_MODEL_REPO", "")
|
| 180 |
+
use_hub = os.getenv("MODEL_SOURCE", "local") == "huggingface_hub" and hf_repo_id
|
| 181 |
+
|
| 182 |
+
if OPTIMUM_AVAILABLE and use_hub:
|
| 183 |
+
try:
|
| 184 |
+
print(f"Loading custom models from HF Hub: {hf_repo_id}")
|
| 185 |
+
self.tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 186 |
+
self.aspect_model = ORTModelForTokenClassification.from_pretrained(
|
| 187 |
+
hf_repo_id, subfolder="aspect_extraction_int8"
|
| 188 |
+
)
|
| 189 |
+
self.sentiment_model = ORTModelForSequenceClassification.from_pretrained(
|
| 190 |
+
hf_repo_id, subfolder="sentiment_int8"
|
| 191 |
+
)
|
| 192 |
+
print("Custom ONNX models loaded.")
|
| 193 |
+
except Exception as e:
|
| 194 |
+
print(f"Custom model load skipped: {e}")
|
| 195 |
+
|
| 196 |
+
elif OPTIMUM_AVAILABLE:
|
| 197 |
+
aspect_path = model_path_base / "aspect_extraction_int8"
|
| 198 |
+
sentiment_path = model_path_base / "sentiment_int8"
|
| 199 |
+
if not aspect_path.exists():
|
| 200 |
+
aspect_path = model_path_base / "aspect_extraction"
|
| 201 |
+
if not sentiment_path.exists():
|
| 202 |
+
sentiment_path = model_path_base / "sentiment"
|
| 203 |
+
|
| 204 |
+
if aspect_path.exists() and sentiment_path.exists():
|
| 205 |
+
try:
|
| 206 |
+
print(f"Loading custom ONNX models from {model_path_base}")
|
| 207 |
+
self.tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
|
| 208 |
+
self.aspect_model = ORTModelForTokenClassification.from_pretrained(str(aspect_path))
|
| 209 |
+
self.sentiment_model = ORTModelForSequenceClassification.from_pretrained(str(sentiment_path))
|
| 210 |
+
print("Custom ONNX models loaded.")
|
| 211 |
+
except Exception as e:
|
| 212 |
+
print(f"Custom model load skipped: {e}")
|
| 213 |
+
|
| 214 |
+
if not self.aspect_model:
|
| 215 |
+
print("No custom models found — using built-in rule-based ABSA engine.")
|
| 216 |
+
|
| 217 |
+
self.is_loaded = True
|
| 218 |
|
| 219 |
def predict(self, text: str, requested_lang: str = None) -> PredictionResponse:
|
| 220 |
+
start = time.time()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
detected_lang = lang_service.detect_language(text)
|
| 222 |
+
actual_lang = requested_lang or detected_lang
|
| 223 |
+
|
| 224 |
+
if self.aspect_model:
|
| 225 |
+
aspects = self._predict_onnx(text)
|
| 226 |
+
else:
|
| 227 |
+
aspects = self._predict_rule_based(text)
|
| 228 |
+
|
| 229 |
+
return PredictionResponse(
|
| 230 |
+
text=text,
|
| 231 |
+
language=actual_lang,
|
| 232 |
+
detected_language=detected_lang,
|
| 233 |
+
aspects=aspects,
|
| 234 |
+
processing_time_ms=(time.time() - start) * 1000,
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
def predict_batch(self, texts: List[str]) -> List[PredictionResponse]:
|
| 238 |
+
return [self.predict(t) for t in texts]
|
| 239 |
+
|
| 240 |
+
# ── Custom ONNX path ──────────────────────────────────────────────────────
|
| 241 |
+
|
| 242 |
+
def _predict_onnx(self, text: str) -> List[AspectSentiment]:
|
| 243 |
inputs = self.tokenizer(text, return_tensors="pt", truncation=True, max_length=128)
|
| 244 |
+
logits = self.aspect_model(**inputs).logits[0].detach().numpy()
|
| 245 |
+
preds = np.argmax(logits, axis=1)
|
|
|
|
|
|
|
| 246 |
tokens = self.tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
|
| 247 |
+
|
| 248 |
+
raw, current, start_idx = [], [], -1
|
| 249 |
+
skip = {self.tokenizer.cls_token, self.tokenizer.sep_token, self.tokenizer.pad_token}
|
| 250 |
+
for idx, (tok, pred) in enumerate(zip(tokens, preds)):
|
| 251 |
+
if tok in skip:
|
|
|
|
|
|
|
|
|
|
| 252 |
continue
|
| 253 |
+
label = self.id2label.get(int(pred), "O")
|
|
|
|
| 254 |
if label == "B-ASP":
|
| 255 |
+
if current:
|
| 256 |
+
raw.append((" ".join(current).strip(), start_idx, idx - 1))
|
| 257 |
+
current, start_idx = [tok], idx
|
| 258 |
+
elif label == "I-ASP" and current:
|
| 259 |
+
current.append(tok)
|
|
|
|
| 260 |
else:
|
| 261 |
+
if current:
|
| 262 |
+
raw.append((" ".join(current).strip(), start_idx, idx - 1))
|
| 263 |
+
current = []
|
| 264 |
+
if current:
|
| 265 |
+
raw.append((" ".join(current).strip(), start_idx, len(tokens) - 1))
|
| 266 |
+
|
|
|
|
|
|
|
| 267 |
results = []
|
| 268 |
+
for asp_text, s, e in raw:
|
| 269 |
+
seq_in = self.tokenizer(text, text_pair=asp_text, return_tensors="pt",
|
| 270 |
+
truncation=True, max_length=128)
|
| 271 |
+
sent_logits = self.sentiment_model(**seq_in).logits[0].detach().numpy()
|
| 272 |
+
exp = np.exp(sent_logits - sent_logits.max())
|
| 273 |
+
probs = exp / exp.sum()
|
| 274 |
+
cls = int(np.argmax(probs))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
results.append(AspectSentiment(
|
| 276 |
+
aspect=asp_text,
|
| 277 |
+
sentiment=self.sentiment_id2label.get(cls, "neutral"),
|
| 278 |
+
confidence=round(float(probs[cls]), 3),
|
| 279 |
+
start=s, end=e,
|
| 280 |
+
))
|
| 281 |
+
return results
|
| 282 |
+
|
| 283 |
+
# ── Rule-based path ───────────────────────────────────────────────────────
|
| 284 |
+
|
| 285 |
+
def _predict_rule_based(self, text: str) -> List[AspectSentiment]:
|
| 286 |
+
text_lower = text.lower()
|
| 287 |
+
sentences = re.split(r"(?<=[.!?])\s+", text)
|
| 288 |
+
found_aspects = self._extract_aspects(text_lower)
|
| 289 |
+
|
| 290 |
+
results = []
|
| 291 |
+
for aspect_label, start_char, end_char in found_aspects:
|
| 292 |
+
# Find the sentence(s) mentioning this aspect for focused scoring
|
| 293 |
+
aspect_lower = aspect_label.lower()
|
| 294 |
+
context_sentences = [
|
| 295 |
+
s for s in sentences if aspect_lower in s.lower()
|
| 296 |
+
] or [text]
|
| 297 |
+
context = " ".join(context_sentences)
|
| 298 |
+
|
| 299 |
+
pos, neg = _score_sentence(context)
|
| 300 |
+
|
| 301 |
+
# Also score the full text with lower weight
|
| 302 |
+
full_pos, full_neg = _score_sentence(text)
|
| 303 |
+
pos += full_pos * 0.3
|
| 304 |
+
neg += full_neg * 0.3
|
| 305 |
+
|
| 306 |
+
sentiment, confidence = _score_to_label(pos, neg)
|
| 307 |
+
results.append(AspectSentiment(
|
| 308 |
+
aspect=aspect_label,
|
| 309 |
sentiment=sentiment,
|
| 310 |
confidence=confidence,
|
| 311 |
+
start=start_char,
|
| 312 |
+
end=end_char,
|
| 313 |
))
|
| 314 |
+
return results
|
| 315 |
+
|
| 316 |
+
def _extract_aspects(self, text_lower: str) -> List[Tuple[str, int, int]]:
|
| 317 |
+
"""Find aspect keyword matches; return (label, start, end) sorted by position."""
|
| 318 |
+
found: List[Tuple[str, int, int]] = []
|
| 319 |
+
seen_ranges: List[Tuple[int, int]] = []
|
| 320 |
+
|
| 321 |
+
for phrase in ASPECT_PHRASES:
|
| 322 |
+
for m in re.finditer(re.escape(phrase), text_lower):
|
| 323 |
+
s, e = m.start(), m.end()
|
| 324 |
+
# Skip if overlaps an already-matched longer phrase
|
| 325 |
+
if any(s >= r0 and e <= r1 for r0, r1 in seen_ranges):
|
| 326 |
+
continue
|
| 327 |
+
label = phrase.title()
|
| 328 |
+
found.append((label, s, e))
|
| 329 |
+
seen_ranges.append((s, e))
|
| 330 |
+
break # one match per phrase
|
| 331 |
+
|
| 332 |
+
found.sort(key=lambda x: x[1])
|
| 333 |
+
return found
|
| 334 |
+
|
|
|
|
| 335 |
|
| 336 |
pipeline = ABSAPipeline()
|
config/docker/Dockerfile.api
CHANGED
|
@@ -6,6 +6,7 @@ COPY requirements.txt .
|
|
| 6 |
|
| 7 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
build-essential \
|
|
|
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
|
@@ -23,7 +24,8 @@ COPY api /app/api
|
|
| 23 |
COPY scripts /app/scripts
|
| 24 |
COPY src /app/src
|
| 25 |
COPY config /app/config
|
| 26 |
-
COPY .env.example /app/.env
|
|
|
|
| 27 |
|
| 28 |
# Add a non-root user
|
| 29 |
RUN adduser --disabled-password --gecos "" absauser \
|
|
|
|
| 6 |
|
| 7 |
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 8 |
build-essential \
|
| 9 |
+
git \
|
| 10 |
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
|
| 12 |
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
|
|
|
|
| 24 |
COPY scripts /app/scripts
|
| 25 |
COPY src /app/src
|
| 26 |
COPY config /app/config
|
| 27 |
+
COPY .env.example /app/.env.example
|
| 28 |
+
# .env is injected via docker-compose environment vars — no need to COPY it
|
| 29 |
|
| 30 |
# Add a non-root user
|
| 31 |
RUN adduser --disabled-password --gecos "" absauser \
|
dashboard/dist/_redirects
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
/* /index.html 200
|
dashboard/dist/assets/index-DGl1EjcM.css
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Geist,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:JetBrains Mono,ui-monospace,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.glass-panel{border-width:1px;border-color:#ffffff0f;background-color:#171f33bf;--tw-backdrop-blur: blur(12px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.nav-item{display:flex;cursor:pointer;align-items:center;gap:.75rem;border-radius:12px;padding:.625rem .75rem;font-size:14px;line-height:20px;font-weight:500;--tw-text-opacity: 1;color:rgb(199 196 215 / var(--tw-text-opacity, 1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.nav-item:hover{background-color:#ffffff0d;--tw-text-opacity: 1;color:rgb(218 226 253 / var(--tw-text-opacity, 1))}.nav-item-active{display:flex;cursor:pointer;align-items:center;gap:.75rem;border-radius:12px;padding:.625rem .75rem;font-size:14px;line-height:20px;font-weight:500;--tw-text-opacity: 1;color:rgb(199 196 215 / var(--tw-text-opacity, 1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.nav-item-active:hover{background-color:#ffffff0d;--tw-text-opacity: 1;color:rgb(218 226 253 / var(--tw-text-opacity, 1))}.nav-item-active{border-right-width:2px;--tw-border-opacity: 1;border-color:rgb(192 193 255 / var(--tw-border-opacity, 1));background-color:#ffffff12;font-weight:600;--tw-text-opacity: 1;color:rgb(192 193 255 / var(--tw-text-opacity, 1))}.badge-positive{display:inline-flex;align-items:center;gap:.375rem;border-radius:9999px;border-width:1px;border-color:#4edea340;background-color:#4edea31a;padding:.125rem .5rem;font-family:JetBrains Mono,ui-monospace,monospace;font-size:12px;line-height:16px;font-weight:500;text-transform:uppercase;letter-spacing:.05em;--tw-text-opacity: 1;color:rgb(78 222 163 / var(--tw-text-opacity, 1))}.badge-negative{display:inline-flex;align-items:center;gap:.375rem;border-radius:9999px;border-width:1px;border-color:#ffb4ab40;background-color:#ffb4ab1a;padding:.125rem .5rem;font-family:JetBrains Mono,ui-monospace,monospace;font-size:12px;line-height:16px;font-weight:500;text-transform:uppercase;letter-spacing:.05em;--tw-text-opacity: 1;color:rgb(255 180 171 / var(--tw-text-opacity, 1))}.badge-neutral{display:inline-flex;align-items:center;gap:.375rem;border-radius:9999px;border-width:1px;border-color:#908fa040;background-color:#908fa01a;padding:.125rem .5rem;font-family:JetBrains Mono,ui-monospace,monospace;font-size:12px;line-height:16px;font-weight:500;text-transform:uppercase;letter-spacing:.05em;--tw-text-opacity: 1;color:rgb(199 196 215 / var(--tw-text-opacity, 1))}.badge-processing{display:inline-flex}.badge-processing{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite;align-items:center;gap:.375rem;border-radius:9999px;border-width:1px;border-color:#c0c1ff40;background-color:#c0c1ff1a;padding:.125rem .5rem;font-family:JetBrains Mono,ui-monospace,monospace;font-size:12px;line-height:16px;font-weight:500;text-transform:uppercase;letter-spacing:.05em;--tw-text-opacity: 1;color:rgb(192 193 255 / var(--tw-text-opacity, 1))}.badge-error{display:inline-flex;align-items:center;gap:.375rem;border-radius:9999px;border-width:1px;border-color:#ffb4ab40;background-color:#ffb4ab1a;padding:.125rem .5rem;font-family:JetBrains Mono,ui-monospace,monospace;font-size:12px;line-height:16px;font-weight:500;text-transform:uppercase;letter-spacing:.05em;--tw-text-opacity: 1;color:rgb(255 180 171 / var(--tw-text-opacity, 1))}.highlight-positive{margin-left:.125rem;margin-right:.125rem;border-radius:6px;border-width:1px;border-color:#4edea34d;background-color:#4edea326;padding-left:.25rem;padding-right:.25rem;font-weight:500;--tw-text-opacity: 1;color:rgb(78 222 163 / var(--tw-text-opacity, 1))}.highlight-negative{margin-left:.125rem;margin-right:.125rem;border-radius:6px;border-width:1px;border-color:#ffb4ab4d;background-color:#ffb4ab26;padding-left:.25rem;padding-right:.25rem;font-weight:500;--tw-text-opacity: 1;color:rgb(255 180 171 / var(--tw-text-opacity, 1))}.highlight-neutral{margin-left:.125rem;margin-right:.125rem;border-radius:6px;border-width:1px;border-color:#908fa040;background-color:#908fa026;padding-left:.25rem;padding-right:.25rem;font-weight:500;--tw-text-opacity: 1;color:rgb(199 196 215 / var(--tw-text-opacity, 1))}.card{border-radius:16px;border-width:1px;border-color:#ffffff14;--tw-bg-opacity: 1;background-color:rgb(23 31 51 / var(--tw-bg-opacity, 1));padding:24px}.card-low{border-radius:16px;border-width:1px;border-color:#ffffff0f;--tw-bg-opacity: 1;background-color:rgb(19 27 46 / var(--tw-bg-opacity, 1));padding:24px}.input-base{border-radius:12px;border-width:1px;border-color:#ffffff1f;--tw-bg-opacity: 1;background-color:rgb(11 19 38 / var(--tw-bg-opacity, 1));padding:.5rem .75rem;font-size:14px;line-height:20px;font-weight:400;--tw-text-opacity: 1;color:rgb(218 226 253 / var(--tw-text-opacity, 1));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.input-base::-moz-placeholder{color:#c7c4d799}.input-base::placeholder{color:#c7c4d799}.input-base:focus{--tw-border-opacity: 1;border-color:rgb(192 193 255 / var(--tw-border-opacity, 1));outline:2px solid transparent;outline-offset:2px;--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000);--tw-ring-color: rgb(192 193 255 / .4)}.btn-primary{display:inline-flex;cursor:pointer;align-items:center;justify-content:center;gap:.5rem;border-radius:12px;--tw-bg-opacity: 1;background-color:rgb(192 193 255 / var(--tw-bg-opacity, 1));padding:.625rem 1.25rem;font-family:JetBrains Mono,ui-monospace,monospace;font-size:12px;line-height:16px;font-weight:500;letter-spacing:.05em;--tw-text-opacity: 1;color:rgb(16 0 169 / var(--tw-text-opacity, 1));transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.btn-primary:hover{--tw-brightness: brightness(1.1);filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.btn-primary:active{--tw-scale-x: .98;--tw-scale-y: .98;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.btn-primary:disabled{cursor:not-allowed;opacity:.5}.btn-primary:active:disabled{--tw-scale-x: 1;--tw-scale-y: 1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.drag-active{border-color:#c0c1ffb3;background-color:#c0c1ff0a}.stat-card{border-radius:16px;border-width:1px;border-color:#ffffff14;--tw-bg-opacity: 1;background-color:rgb(23 31 51 / var(--tw-bg-opacity, 1));padding:24px;position:relative;overflow:hidden}.pointer-events-none{pointer-events:none}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.inset-0{top:0;right:0;bottom:0;left:0}.bottom-0{bottom:0}.left-0{left:0}.right-0{right:0}.top-0{top:0}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-lg{margin-bottom:16px}.mb-xl{margin-bottom:24px}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-auto{margin-top:auto}.mt-lg{margin-top:16px}.inline-block{display:inline-block}.flex{display:flex}.table{display:table}.grid{display:grid}.hidden{display:none}.h-1{height:.25rem}.h-1\.5{height:.375rem}.h-16{height:4rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-4{height:1rem}.h-8{height:2rem}.h-96{height:24rem}.h-full{height:100%}.h-screen{height:100vh}.min-h-\[260px\]{min-height:260px}.min-h-screen{min-height:100vh}.w-1\.5{width:.375rem}.w-10{width:2.5rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-2{width:.5rem}.w-20{width:5rem}.w-28{width:7rem}.w-3{width:.75rem}.w-4\/6{width:66.666667%}.w-5\/6{width:83.333333%}.w-64{width:16rem}.w-8{width:2rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-\[1280px\]{max-width:1280px}.max-w-sm{max-width:24rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.border-collapse{border-collapse:collapse}.-translate-x-full{--tw-translate-x: -100%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.translate-x-0{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.animate-fade-in{animation:fadeIn .2s ease-out}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}@keyframes pulse{50%{opacity:.5}}.animate-pulse-slow{animation:pulse 3s cubic-bezier(.4,0,.6,1) infinite}@keyframes slideIn{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.animate-slide-in{animation:slideIn .25s ease-out}@keyframes spin{to{transform:rotate(360deg)}}.animate-spin{animation:spin 1s linear infinite}.cursor-pointer{cursor:pointer}.select-none{-webkit-user-select:none;-moz-user-select:none;user-select:none}.resize-none{resize:none}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-lg{gap:16px}.gap-md{gap:12px}.gap-xl{gap:24px}.space-y-0\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.125rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.125rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-xl>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(24px * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(24px * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-white\/\[0\.05\]>:not([hidden])~:not([hidden]){border-color:#ffffff0d}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rounded{border-radius:6px}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:12px}.rounded-xl{border-radius:16px}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-gray-200{--tw-border-opacity: 1;border-color:rgb(229 231 235 / var(--tw-border-opacity, 1))}.border-primary\/60{border-color:#c0c1ff99}.border-white\/\[0\.05\]{border-color:#ffffff0d}.border-white\/\[0\.06\]{border-color:#ffffff0f}.border-white\/\[0\.07\]{border-color:#ffffff12}.border-white\/\[0\.08\]{border-color:#ffffff14}.border-white\/\[0\.12\]{border-color:#ffffff1f}.border-white\/\[0\.14\]{border-color:#ffffff24}.bg-background{--tw-bg-opacity: 1;background-color:rgb(11 19 38 / var(--tw-bg-opacity, 1))}.bg-black\/60{background-color:#0009}.bg-error{--tw-bg-opacity: 1;background-color:rgb(255 180 171 / var(--tw-bg-opacity, 1))}.bg-error\/10{background-color:#ffb4ab1a}.bg-outline{--tw-bg-opacity: 1;background-color:rgb(144 143 160 / var(--tw-bg-opacity, 1))}.bg-primary{--tw-bg-opacity: 1;background-color:rgb(192 193 255 / var(--tw-bg-opacity, 1))}.bg-primary\/10{background-color:#c0c1ff1a}.bg-surface{--tw-bg-opacity: 1;background-color:rgb(11 19 38 / var(--tw-bg-opacity, 1))}.bg-surface-container{--tw-bg-opacity: 1;background-color:rgb(23 31 51 / var(--tw-bg-opacity, 1))}.bg-surface-container-high{--tw-bg-opacity: 1;background-color:rgb(34 42 61 / var(--tw-bg-opacity, 1))}.bg-surface-container-high\/50{background-color:#222a3d80}.bg-surface-container-highest{--tw-bg-opacity: 1;background-color:rgb(45 52 73 / var(--tw-bg-opacity, 1))}.bg-surface-container-low{--tw-bg-opacity: 1;background-color:rgb(19 27 46 / var(--tw-bg-opacity, 1))}.bg-surface\/80{background-color:#0b1326cc}.bg-tertiary{--tw-bg-opacity: 1;background-color:rgb(78 222 163 / var(--tw-bg-opacity, 1))}.bg-tertiary\/10{background-color:#4edea31a}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.p-0{padding:0}.p-12{padding:3rem}.p-2{padding:.5rem}.p-2\.5{padding:.625rem}.p-6{padding:1.5rem}.p-lg{padding:16px}.p-md{padding:12px}.p-xl{padding:24px}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-lg{padding-left:16px;padding-right:16px}.px-md{padding-left:12px;padding-right:12px}.px-sm{padding-left:8px;padding-right:8px}.px-xl{padding-left:24px;padding-right:24px}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.py-xl{padding-top:24px;padding-bottom:24px}.pb-md{padding-bottom:12px}.pb-xl{padding-bottom:24px}.pr-1{padding-right:.25rem}.pt-16{padding-top:4rem}.pt-lg{padding-top:16px}.pt-sm{padding-top:8px}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:JetBrains Mono,ui-monospace,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-5xl{font-size:3rem;line-height:1}.text-\[16px\]{font-size:16px}.text-body-md{font-size:14px;line-height:20px;font-weight:400}.text-body-sm{font-size:12px;line-height:16px;font-weight:400}.text-display{font-size:36px;line-height:44px;letter-spacing:-.025em;font-weight:600}.text-headline-md{font-size:24px;line-height:32px;letter-spacing:-.01em;font-weight:600}.text-headline-sm{font-size:20px;line-height:28px;font-weight:500}.text-label-md{font-size:12px;line-height:16px;letter-spacing:.05em;font-weight:500}.text-label-sm{font-size:11px;line-height:16px;letter-spacing:.06em;font-weight:500}.text-sm{font-size:.875rem;line-height:1.25rem}.text-title-lg{font-size:16px;line-height:24px;font-weight:600}.text-xs{font-size:.75rem;line-height:1rem}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-loose{line-height:2}.leading-relaxed{line-height:1.625}.leading-tight{line-height:1.25}.tracking-wider{letter-spacing:.05em}.text-error{--tw-text-opacity: 1;color:rgb(255 180 171 / var(--tw-text-opacity, 1))}.text-gray-900{--tw-text-opacity: 1;color:rgb(17 24 39 / var(--tw-text-opacity, 1))}.text-on-surface{--tw-text-opacity: 1;color:rgb(218 226 253 / var(--tw-text-opacity, 1))}.text-on-surface-variant{--tw-text-opacity: 1;color:rgb(199 196 215 / var(--tw-text-opacity, 1))}.text-on-surface-variant\/50{color:#c7c4d780}.text-primary{--tw-text-opacity: 1;color:rgb(192 193 255 / var(--tw-text-opacity, 1))}.text-tertiary{--tw-text-opacity: 1;color:rgb(78 222 163 / var(--tw-text-opacity, 1))}.text-warning{--tw-text-opacity: 1;color:rgb(245 197 66 / var(--tw-text-opacity, 1))}.opacity-30{opacity:.3}.opacity-40{opacity:.4}.opacity-60{opacity:.6}.shadow-glow-negative{--tw-shadow: 0 0 12px rgba(255,180,171,.2);--tw-shadow-colored: 0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-glow-positive{--tw-shadow: 0 0 12px rgba(78,222,163,.2);--tw-shadow-colored: 0 0 12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.backdrop-blur-glass{--tw-backdrop-blur: blur(12px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-sm{--tw-backdrop-blur: blur(4px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-150{transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-500{transition-duration:.5s}.ease-out{transition-timing-function:cubic-bezier(0,0,.2,1)}html{scroll-behavior:smooth;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}body{background-color:#0b1326;color:#dae2fd;font-family:Geist,ui-sans-serif,system-ui,sans-serif;min-height:100vh}html{color-scheme:dark}.material-symbols-outlined{font-family:Material Symbols Outlined;font-weight:400;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;-webkit-user-select:none;-moz-user-select:none;user-select:none;vertical-align:middle}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:#ffffff1f;border-radius:3px}::-webkit-scrollbar-thumb:hover{background:#ffffff38}:focus-visible{outline:2px solid #c0c1ff;outline-offset:2px}.hover\:border-primary\/40:hover{border-color:#c0c1ff66}.hover\:border-white\/25:hover{border-color:#ffffff40}.hover\:border-white\/\[0\.12\]:hover{border-color:#ffffff1f}.hover\:bg-primary\/\[0\.02\]:hover{background-color:#c0c1ff05}.hover\:bg-white\/\[0\.03\]:hover{background-color:#ffffff08}.hover\:bg-white\/\[0\.05\]:hover{background-color:#ffffff0d}.hover\:text-on-surface:hover{--tw-text-opacity: 1;color:rgb(218 226 253 / var(--tw-text-opacity, 1))}.group:hover .group-hover\:opacity-70{opacity:.7}.dark\:border-slate-700:is(.dark *){--tw-border-opacity: 1;border-color:rgb(51 65 85 / var(--tw-border-opacity, 1))}.dark\:bg-slate-800:is(.dark *){--tw-bg-opacity: 1;background-color:rgb(30 41 59 / var(--tw-bg-opacity, 1))}.dark\:text-white:is(.dark *){--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}@media (min-width: 640px){.sm\:block{display:block}.sm\:flex{display:flex}.sm\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width: 768px){.md\:left-64{left:16rem}.md\:ml-64{margin-left:16rem}.md\:block{display:block}.md\:hidden{display:none}.md\:translate-x-0{--tw-translate-x: 0px;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.md\:px-3xl{padding-left:48px;padding-right:48px}.md\:py-2xl{padding-top:32px;padding-bottom:32px}}@media (min-width: 1024px){.lg\:col-span-2{grid-column:span 2 / span 2}.lg\:col-span-4{grid-column:span 4 / span 4}.lg\:col-span-5{grid-column:span 5 / span 5}.lg\:col-span-7{grid-column:span 7 / span 7}.lg\:col-span-8{grid-column:span 8 / span 8}.lg\:grid-cols-12{grid-template-columns:repeat(12,minmax(0,1fr))}.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width: 1280px){.xl\:col-span-2{grid-column:span 2 / span 2}.xl\:col-span-3{grid-column:span 3 / span 3}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}
|
dashboard/dist/assets/index-w96O4ZPi.js
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dashboard/dist/index.html
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>SentimentAI — Multilingual ABSA</title>
|
| 8 |
+
<meta name="description" content="Aspect-Based Sentiment Analysis for English and Hindi product reviews." />
|
| 9 |
+
|
| 10 |
+
<!-- Google Fonts -->
|
| 11 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 12 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 13 |
+
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet" />
|
| 14 |
+
|
| 15 |
+
<!-- Material Symbols -->
|
| 16 |
+
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet" />
|
| 17 |
+
<script type="module" crossorigin src="/assets/index-w96O4ZPi.js"></script>
|
| 18 |
+
<link rel="stylesheet" crossorigin href="/assets/index-DGl1EjcM.css">
|
| 19 |
+
</head>
|
| 20 |
+
<body>
|
| 21 |
+
<div id="root"></div>
|
| 22 |
+
</body>
|
| 23 |
+
</html>
|
dashboard/index.html
CHANGED
|
@@ -1,12 +1,21 @@
|
|
| 1 |
-
<!
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<title>SentimentAI
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
</head>
|
| 9 |
-
<body
|
| 10 |
<div id="root"></div>
|
| 11 |
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
</body>
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>SentimentAI — Multilingual ABSA</title>
|
| 8 |
+
<meta name="description" content="Aspect-Based Sentiment Analysis for English and Hindi product reviews." />
|
| 9 |
+
|
| 10 |
+
<!-- Google Fonts -->
|
| 11 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 12 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 13 |
+
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600&family=JetBrains+Mono:wght@500&display=swap" rel="stylesheet" />
|
| 14 |
+
|
| 15 |
+
<!-- Material Symbols -->
|
| 16 |
+
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet" />
|
| 17 |
</head>
|
| 18 |
+
<body>
|
| 19 |
<div id="root"></div>
|
| 20 |
<script type="module" src="/src/main.jsx"></script>
|
| 21 |
</body>
|
dashboard/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
dashboard/src/App.jsx
CHANGED
|
@@ -9,12 +9,11 @@ import Monitor from './pages/Monitor'
|
|
| 9 |
function App() {
|
| 10 |
return (
|
| 11 |
<BrowserRouter>
|
| 12 |
-
<Toaster position="top-right" />
|
| 13 |
<Routes>
|
| 14 |
<Route path="/" element={<Layout />}>
|
| 15 |
<Route index element={<Navigate to="/predict" replace />} />
|
| 16 |
<Route path="predict" element={<Predict />} />
|
| 17 |
-
<Route path="
|
| 18 |
<Route path="monitor" element={<Monitor />} />
|
| 19 |
</Route>
|
| 20 |
</Routes>
|
|
|
|
| 9 |
function App() {
|
| 10 |
return (
|
| 11 |
<BrowserRouter>
|
|
|
|
| 12 |
<Routes>
|
| 13 |
<Route path="/" element={<Layout />}>
|
| 14 |
<Route index element={<Navigate to="/predict" replace />} />
|
| 15 |
<Route path="predict" element={<Predict />} />
|
| 16 |
+
<Route path="batch" element={<Analytics />} />
|
| 17 |
<Route path="monitor" element={<Monitor />} />
|
| 18 |
</Route>
|
| 19 |
</Routes>
|
dashboard/src/components/Layout.jsx
CHANGED
|
@@ -1,24 +1,106 @@
|
|
| 1 |
import React, { useState } from 'react'
|
| 2 |
-
import { Outlet } from 'react-router-dom'
|
| 3 |
-
import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
export default function Layout() {
|
| 6 |
-
const [
|
|
|
|
|
|
|
| 7 |
|
| 8 |
return (
|
| 9 |
-
<div className="min-h-screen bg-
|
| 10 |
-
<
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
/>
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
SentimentAI — Phase 6 Analytics Dashboard
|
| 20 |
-
</
|
| 21 |
-
</
|
| 22 |
</div>
|
| 23 |
)
|
| 24 |
}
|
|
|
|
| 1 |
import React, { useState } from 'react'
|
| 2 |
+
import { Outlet, useLocation } from 'react-router-dom'
|
| 3 |
+
import { Toaster } from 'react-hot-toast'
|
| 4 |
+
import Sidebar, { MSIcon } from './Sidebar'
|
| 5 |
+
|
| 6 |
+
const PAGE_TITLES = {
|
| 7 |
+
'/predict': 'Live Predictor',
|
| 8 |
+
'/batch': 'Batch Analytics',
|
| 9 |
+
'/monitor': 'System Monitor',
|
| 10 |
+
}
|
| 11 |
|
| 12 |
export default function Layout() {
|
| 13 |
+
const [sidebarOpen, setSidebarOpen] = useState(false)
|
| 14 |
+
const location = useLocation()
|
| 15 |
+
const pageTitle = PAGE_TITLES[location.pathname] || 'SentimentAI'
|
| 16 |
|
| 17 |
return (
|
| 18 |
+
<div className="min-h-screen bg-background flex">
|
| 19 |
+
<Toaster
|
| 20 |
+
position="top-right"
|
| 21 |
+
toastOptions={{
|
| 22 |
+
style: {
|
| 23 |
+
background: '#222a3d',
|
| 24 |
+
color: '#dae2fd',
|
| 25 |
+
border: '1px solid rgba(255,255,255,0.08)',
|
| 26 |
+
borderRadius: '8px',
|
| 27 |
+
fontSize: '14px',
|
| 28 |
+
},
|
| 29 |
+
success: { iconTheme: { primary: '#4edea3', secondary: '#0b1326' } },
|
| 30 |
+
error: { iconTheme: { primary: '#ffb4ab', secondary: '#0b1326' } },
|
| 31 |
+
}}
|
| 32 |
/>
|
| 33 |
+
|
| 34 |
+
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
|
| 35 |
+
|
| 36 |
+
{/* Right of sidebar */}
|
| 37 |
+
<div className="flex-1 md:ml-64 flex flex-col min-h-screen">
|
| 38 |
+
{/* Top bar */}
|
| 39 |
+
<header className="fixed top-0 right-0 left-0 md:left-64 h-16 z-30
|
| 40 |
+
bg-surface/80 backdrop-blur-glass border-b border-white/[0.06]
|
| 41 |
+
flex items-center justify-between px-xl gap-4">
|
| 42 |
+
{/* Mobile hamburger + brand */}
|
| 43 |
+
<div className="flex items-center gap-3">
|
| 44 |
+
<button
|
| 45 |
+
onClick={() => setSidebarOpen(true)}
|
| 46 |
+
className="md:hidden p-2 rounded-lg text-on-surface-variant hover:text-on-surface
|
| 47 |
+
hover:bg-white/[0.05] transition-colors"
|
| 48 |
+
aria-label="Open navigation"
|
| 49 |
+
>
|
| 50 |
+
<MSIcon name="menu" size={22} />
|
| 51 |
+
</button>
|
| 52 |
+
<div className="md:hidden flex items-center gap-2">
|
| 53 |
+
<MSIcon name="psychology" filled size={22} className="text-primary" />
|
| 54 |
+
<span className="font-semibold text-on-surface">SentimentAI</span>
|
| 55 |
+
</div>
|
| 56 |
+
<h2 className="hidden md:block text-body-md font-medium text-on-surface-variant">
|
| 57 |
+
{pageTitle}
|
| 58 |
+
</h2>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
{/* Right actions */}
|
| 62 |
+
<div className="flex items-center gap-2">
|
| 63 |
+
<button
|
| 64 |
+
className="p-2 rounded-lg text-on-surface-variant hover:text-on-surface
|
| 65 |
+
hover:bg-white/[0.05] transition-colors"
|
| 66 |
+
aria-label="Notifications"
|
| 67 |
+
>
|
| 68 |
+
<MSIcon name="notifications" size={20} />
|
| 69 |
+
</button>
|
| 70 |
+
<a
|
| 71 |
+
href="http://localhost:8000/docs"
|
| 72 |
+
target="_blank"
|
| 73 |
+
rel="noopener noreferrer"
|
| 74 |
+
className="hidden sm:flex items-center gap-1.5 px-3 py-1.5
|
| 75 |
+
border border-white/[0.12] rounded-lg
|
| 76 |
+
font-mono text-label-md text-on-surface-variant
|
| 77 |
+
hover:text-on-surface hover:border-white/25
|
| 78 |
+
transition-colors duration-150"
|
| 79 |
+
>
|
| 80 |
+
<MSIcon name="api" size={14} />
|
| 81 |
+
API Docs
|
| 82 |
+
</a>
|
| 83 |
+
{/* Avatar placeholder */}
|
| 84 |
+
<div className="w-8 h-8 rounded-full bg-surface-container-highest
|
| 85 |
+
border border-white/[0.12] flex items-center justify-center
|
| 86 |
+
text-on-surface-variant text-xs font-medium select-none">
|
| 87 |
+
<MSIcon name="person" size={18} />
|
| 88 |
+
</div>
|
| 89 |
+
</div>
|
| 90 |
+
</header>
|
| 91 |
+
|
| 92 |
+
{/* Page content */}
|
| 93 |
+
<main className="flex-1 pt-16 overflow-y-auto">
|
| 94 |
+
<div className="max-w-[1280px] mx-auto px-lg md:px-3xl py-xl md:py-2xl">
|
| 95 |
+
<Outlet />
|
| 96 |
+
</div>
|
| 97 |
+
</main>
|
| 98 |
+
|
| 99 |
+
{/* Footer */}
|
| 100 |
+
<footer className="border-t border-white/[0.05] py-4 text-center font-mono text-label-sm text-on-surface-variant">
|
| 101 |
SentimentAI — Phase 6 Analytics Dashboard
|
| 102 |
+
</footer>
|
| 103 |
+
</div>
|
| 104 |
</div>
|
| 105 |
)
|
| 106 |
}
|
dashboard/src/components/LivePredictor.jsx
CHANGED
|
@@ -1,18 +1,140 @@
|
|
| 1 |
import React, { useState } from 'react'
|
| 2 |
import { useMutation } from '@tanstack/react-query'
|
| 3 |
import { api } from '../api/client'
|
| 4 |
-
import {
|
| 5 |
-
|
| 6 |
-
const
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
}
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
export default function LivePredictor() {
|
| 17 |
const [text, setText] = useState('')
|
| 18 |
const [language, setLanguage] = useState('')
|
|
@@ -26,163 +148,149 @@ export default function LivePredictor() {
|
|
| 26 |
mutation.mutate({ text, language })
|
| 27 |
}
|
| 28 |
|
| 29 |
-
const
|
| 30 |
-
if (
|
| 31 |
-
|
| 32 |
-
// Sort aspects by start position
|
| 33 |
-
const sortedAspects = [...aspects].sort((a, b) => a.start - b.start)
|
| 34 |
-
|
| 35 |
-
let lastIndex = 0
|
| 36 |
-
const parts = []
|
| 37 |
-
|
| 38 |
-
sortedAspects.forEach((asp, i) => {
|
| 39 |
-
// Add text before aspect
|
| 40 |
-
if (asp.start > lastIndex) {
|
| 41 |
-
parts.push(<span key={`text-${i}`}>{originalText.substring(lastIndex, asp.start)}</span>)
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
// Add aspect
|
| 45 |
-
const colorClass = getSentimentColor(asp.sentiment)
|
| 46 |
-
parts.push(
|
| 47 |
-
<span key={`asp-${i}`} className={`px-1 rounded font-medium border ${colorClass}`}>
|
| 48 |
-
{originalText.substring(asp.start, asp.end + 1)}
|
| 49 |
-
</span>
|
| 50 |
-
)
|
| 51 |
-
|
| 52 |
-
lastIndex = asp.end + 1
|
| 53 |
-
})
|
| 54 |
-
|
| 55 |
-
// Add remaining text
|
| 56 |
-
if (lastIndex < originalText.length) {
|
| 57 |
-
parts.push(<span key="text-end">{originalText.substring(lastIndex)}</span>)
|
| 58 |
-
}
|
| 59 |
-
|
| 60 |
-
return <p className="text-gray-700 dark:text-gray-300 leading-relaxed">{parts}</p>
|
| 61 |
}
|
| 62 |
|
|
|
|
|
|
|
| 63 |
return (
|
| 64 |
-
<div className="grid grid-cols-1 lg:grid-cols-
|
| 65 |
-
{/*
|
| 66 |
-
<div className="
|
| 67 |
-
<
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
<textarea
|
| 90 |
-
value={text}
|
| 91 |
-
onChange={(e) => setText(e.target.value)}
|
| 92 |
-
maxLength={512}
|
| 93 |
-
placeholder="Type a product review here..."
|
| 94 |
-
className="flex-1 w-full rounded-md border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none min-h-[200px]"
|
| 95 |
-
/>
|
| 96 |
-
<div className="flex justify-end mt-1">
|
| 97 |
-
<span className={`text-xs ${text.length >= 512 ? 'text-red-500' : 'text-gray-500 dark:text-gray-400'}`}>
|
| 98 |
-
{text.length} / 512
|
| 99 |
-
</span>
|
| 100 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
</div>
|
| 102 |
-
|
| 103 |
-
<button
|
| 104 |
-
onClick={handlePredict}
|
| 105 |
-
disabled={mutation.isPending || !text.trim()}
|
| 106 |
-
className="w-full flex justify-center items-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 107 |
-
>
|
| 108 |
-
{mutation.isPending ? (
|
| 109 |
-
<>
|
| 110 |
-
<Loader2 className="animate-spin -ml-1 mr-2 h-4 w-4" />
|
| 111 |
-
Analyzing...
|
| 112 |
-
</>
|
| 113 |
-
) : (
|
| 114 |
-
'Analyze'
|
| 115 |
-
)}
|
| 116 |
-
</button>
|
| 117 |
</div>
|
| 118 |
|
| 119 |
-
{/*
|
| 120 |
-
<div className="
|
| 121 |
-
<
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
{mutation.data && (
|
| 137 |
-
<div className="flex flex-col h-full overflow-hidden">
|
| 138 |
-
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200 dark:border-slate-700">
|
| 139 |
-
<div className="flex items-center gap-2">
|
| 140 |
-
<span className="text-sm text-gray-500 dark:text-gray-400">Detected Language:</span>
|
| 141 |
-
<span className="px-2 py-1 bg-indigo-100 text-indigo-800 dark:bg-indigo-900/50 dark:text-indigo-200 rounded text-xs font-semibold uppercase">
|
| 142 |
-
{mutation.data.detected_language}
|
| 143 |
</span>
|
| 144 |
</div>
|
| 145 |
-
|
| 146 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
</div>
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
></div>
|
| 173 |
-
</div>
|
| 174 |
-
<span>{Math.round(asp.confidence * 100)}%</span>
|
| 175 |
-
</div>
|
| 176 |
</div>
|
| 177 |
-
)
|
| 178 |
-
|
| 179 |
-
<div className="text-center py-6 text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-slate-800/50 rounded-lg border border-dashed border-gray-300 dark:border-slate-600">
|
| 180 |
-
No specific aspects detected
|
| 181 |
-
</div>
|
| 182 |
-
)}
|
| 183 |
</div>
|
| 184 |
-
|
| 185 |
-
|
| 186 |
</div>
|
| 187 |
</div>
|
| 188 |
)
|
|
|
|
| 1 |
import React, { useState } from 'react'
|
| 2 |
import { useMutation } from '@tanstack/react-query'
|
| 3 |
import { api } from '../api/client'
|
| 4 |
+
import { MSIcon } from './Sidebar'
|
| 5 |
+
|
| 6 |
+
const MAX_CHARS = 512
|
| 7 |
+
|
| 8 |
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
| 9 |
+
|
| 10 |
+
function sentimentBadgeClass(s) {
|
| 11 |
+
switch (s) {
|
| 12 |
+
case 'positive': return 'badge-positive'
|
| 13 |
+
case 'negative': return 'badge-negative'
|
| 14 |
+
case 'conflict': return 'badge-error'
|
| 15 |
+
default: return 'badge-neutral'
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
function highlightClass(s) {
|
| 20 |
+
switch (s) {
|
| 21 |
+
case 'positive': return 'highlight-positive'
|
| 22 |
+
case 'negative': return 'highlight-negative'
|
| 23 |
+
case 'conflict': return 'highlight-negative'
|
| 24 |
+
default: return 'highlight-neutral'
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
function confidenceBarColor(s) {
|
| 29 |
+
if (s === 'positive') return 'bg-tertiary'
|
| 30 |
+
if (s === 'negative' || s === 'conflict') return 'bg-error'
|
| 31 |
+
return 'bg-outline'
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function sentimentDot(s) {
|
| 35 |
+
if (s === 'positive') return 'bg-tertiary'
|
| 36 |
+
if (s === 'negative' || s === 'conflict') return 'bg-error'
|
| 37 |
+
return 'bg-outline'
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Build annotated text spans from API response
|
| 41 |
+
function AnnotatedText({ text, aspects }) {
|
| 42 |
+
if (!aspects || aspects.length === 0) {
|
| 43 |
+
return <p className="text-body-md text-on-surface leading-loose">{text}</p>
|
| 44 |
}
|
| 45 |
+
|
| 46 |
+
const sorted = [...aspects].sort((a, b) => a.start - b.start)
|
| 47 |
+
const parts = []
|
| 48 |
+
let cursor = 0
|
| 49 |
+
|
| 50 |
+
sorted.forEach((asp, i) => {
|
| 51 |
+
if (asp.start > cursor) {
|
| 52 |
+
parts.push(<span key={`t${i}`}>{text.slice(cursor, asp.start)}</span>)
|
| 53 |
+
}
|
| 54 |
+
parts.push(
|
| 55 |
+
<span key={`a${i}`} className={highlightClass(asp.sentiment)} title={`${asp.sentiment} · ${Math.round(asp.confidence * 100)}%`}>
|
| 56 |
+
{text.slice(asp.start, asp.end)}
|
| 57 |
+
</span>
|
| 58 |
+
)
|
| 59 |
+
cursor = asp.end
|
| 60 |
+
})
|
| 61 |
+
|
| 62 |
+
if (cursor < text.length) parts.push(<span key="tend">{text.slice(cursor)}</span>)
|
| 63 |
+
|
| 64 |
+
return <p className="text-body-md text-on-surface leading-loose">{parts}</p>
|
| 65 |
}
|
| 66 |
|
| 67 |
+
// ── Skeleton ─────────────────────────────────────────────────────────────────
|
| 68 |
+
function Skeleton({ className = '' }) {
|
| 69 |
+
return (
|
| 70 |
+
<div className={`animate-pulse bg-surface-container-high rounded ${className}`} />
|
| 71 |
+
)
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
// ── Empty / Loading state for results panel ──────────────────────────────────
|
| 75 |
+
function ResultsEmpty() {
|
| 76 |
+
return (
|
| 77 |
+
<div className="flex-1 flex flex-col items-center justify-center gap-4 py-16 text-on-surface-variant">
|
| 78 |
+
<span className="material-symbols-outlined text-5xl opacity-30"
|
| 79 |
+
style={{ fontVariationSettings: `'FILL' 0, 'wght' 200` }}>
|
| 80 |
+
psychology
|
| 81 |
+
</span>
|
| 82 |
+
<p className="text-body-md opacity-60">Enter a review and click Analyze to see results</p>
|
| 83 |
+
</div>
|
| 84 |
+
)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function ResultsLoading() {
|
| 88 |
+
return (
|
| 89 |
+
<div className="flex flex-col gap-4 animate-fade-in">
|
| 90 |
+
<div className="bg-surface rounded-lg p-lg space-y-3">
|
| 91 |
+
<Skeleton className="h-4 w-full" />
|
| 92 |
+
<Skeleton className="h-4 w-5/6" />
|
| 93 |
+
<Skeleton className="h-4 w-4/6" />
|
| 94 |
+
</div>
|
| 95 |
+
{[1, 2, 3].map(i => (
|
| 96 |
+
<div key={i} className="bg-surface-container rounded-lg p-md space-y-2 border border-white/[0.06]">
|
| 97 |
+
<div className="flex justify-between">
|
| 98 |
+
<Skeleton className="h-4 w-28" />
|
| 99 |
+
<Skeleton className="h-4 w-16" />
|
| 100 |
+
</div>
|
| 101 |
+
<Skeleton className="h-1.5 w-full" />
|
| 102 |
+
</div>
|
| 103 |
+
))}
|
| 104 |
+
</div>
|
| 105 |
+
)
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
// ── Aspect Card ───────────────────────────────────────────────────────────────
|
| 109 |
+
function AspectCard({ asp }) {
|
| 110 |
+
const pct = Math.round(asp.confidence * 100)
|
| 111 |
+
return (
|
| 112 |
+
<div className="bg-surface-container rounded-lg p-md border border-white/[0.06] hover:border-white/[0.12] transition-colors animate-slide-in">
|
| 113 |
+
<div className="flex justify-between items-center mb-2">
|
| 114 |
+
<span className="text-body-md text-on-surface font-medium">{asp.aspect}</span>
|
| 115 |
+
<span className={sentimentBadgeClass(asp.sentiment)}>
|
| 116 |
+
<span className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${sentimentDot(asp.sentiment)}`} />
|
| 117 |
+
{asp.sentiment}
|
| 118 |
+
</span>
|
| 119 |
+
</div>
|
| 120 |
+
<div className="flex items-center gap-2">
|
| 121 |
+
<span className="font-mono text-label-sm text-on-surface-variant w-20">Confidence</span>
|
| 122 |
+
<div className="flex-1 h-1 bg-surface rounded-full overflow-hidden">
|
| 123 |
+
<div
|
| 124 |
+
className={`h-full rounded-full transition-all duration-500 ${confidenceBarColor(asp.sentiment)}`}
|
| 125 |
+
style={{ width: `${pct}%` }}
|
| 126 |
+
/>
|
| 127 |
+
</div>
|
| 128 |
+
<span className={`font-mono text-label-sm w-8 text-right ${
|
| 129 |
+
asp.sentiment === 'positive' ? 'text-tertiary' :
|
| 130 |
+
asp.sentiment === 'negative' ? 'text-error' : 'text-on-surface-variant'
|
| 131 |
+
}`}>{pct}%</span>
|
| 132 |
+
</div>
|
| 133 |
+
</div>
|
| 134 |
+
)
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
// ── Main Component ────────────────────────────────────────────────────────────
|
| 138 |
export default function LivePredictor() {
|
| 139 |
const [text, setText] = useState('')
|
| 140 |
const [language, setLanguage] = useState('')
|
|
|
|
| 148 |
mutation.mutate({ text, language })
|
| 149 |
}
|
| 150 |
|
| 151 |
+
const handleKeyDown = (e) => {
|
| 152 |
+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') handlePredict()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
}
|
| 154 |
|
| 155 |
+
const data = mutation.data
|
| 156 |
+
|
| 157 |
return (
|
| 158 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-xl">
|
| 159 |
+
{/* ── Left: Input ─────────────────────────────────────── */}
|
| 160 |
+
<div className="lg:col-span-7 flex flex-col">
|
| 161 |
+
<div className="card-low flex flex-col h-full">
|
| 162 |
+
{/* Card header */}
|
| 163 |
+
<div className="flex justify-between items-center mb-lg pb-md border-b border-white/[0.06]">
|
| 164 |
+
<h3 className="font-mono text-label-md text-on-surface-variant uppercase tracking-wider">
|
| 165 |
+
Analyze Input
|
| 166 |
+
</h3>
|
| 167 |
+
<div className="flex items-center gap-2">
|
| 168 |
+
<label htmlFor="lang-select" className="font-mono text-label-sm text-on-surface-variant">
|
| 169 |
+
Language
|
| 170 |
+
</label>
|
| 171 |
+
<select
|
| 172 |
+
id="lang-select"
|
| 173 |
+
value={language}
|
| 174 |
+
onChange={(e) => setLanguage(e.target.value)}
|
| 175 |
+
className="input-base py-1 text-body-sm"
|
| 176 |
+
>
|
| 177 |
+
<option value="">Auto-detect</option>
|
| 178 |
+
<option value="en">English</option>
|
| 179 |
+
<option value="hi">Hindi</option>
|
| 180 |
+
<option value="hinglish">Hinglish</option>
|
| 181 |
+
</select>
|
| 182 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
</div>
|
| 184 |
+
|
| 185 |
+
{/* Textarea */}
|
| 186 |
+
<div className="flex-1 flex flex-col mb-lg">
|
| 187 |
+
<label htmlFor="review-text" className="font-mono text-label-sm text-on-surface-variant mb-2">
|
| 188 |
+
Source Text
|
| 189 |
+
</label>
|
| 190 |
+
<textarea
|
| 191 |
+
id="review-text"
|
| 192 |
+
value={text}
|
| 193 |
+
onChange={(e) => setText(e.target.value)}
|
| 194 |
+
onKeyDown={handleKeyDown}
|
| 195 |
+
maxLength={MAX_CHARS}
|
| 196 |
+
placeholder="Paste your review, article, or social media post here…"
|
| 197 |
+
aria-label="Review text input"
|
| 198 |
+
className="input-base flex-1 min-h-[260px] resize-none leading-relaxed"
|
| 199 |
+
/>
|
| 200 |
+
<div className="flex justify-between mt-2">
|
| 201 |
+
<span className="font-mono text-label-sm text-on-surface-variant/50">
|
| 202 |
+
⌘ Enter to analyze
|
| 203 |
+
</span>
|
| 204 |
+
<span className={`font-mono text-label-sm ${text.length >= MAX_CHARS ? 'text-error' : 'text-on-surface-variant/50'}`}>
|
| 205 |
+
{text.length} / {MAX_CHARS}
|
| 206 |
+
</span>
|
| 207 |
+
</div>
|
| 208 |
+
</div>
|
| 209 |
+
|
| 210 |
+
{/* Analyze button */}
|
| 211 |
+
<button
|
| 212 |
+
onClick={handlePredict}
|
| 213 |
+
disabled={mutation.isPending || !text.trim()}
|
| 214 |
+
className="btn-primary"
|
| 215 |
+
aria-label="Run sentiment analysis"
|
| 216 |
+
>
|
| 217 |
+
{mutation.isPending ? (
|
| 218 |
+
<>
|
| 219 |
+
<span className="material-symbols-outlined animate-spin text-[16px]">progress_activity</span>
|
| 220 |
+
Analyzing…
|
| 221 |
+
</>
|
| 222 |
+
) : (
|
| 223 |
+
<>
|
| 224 |
+
<MSIcon name="bolt" size={16} />
|
| 225 |
+
Analyze
|
| 226 |
+
</>
|
| 227 |
+
)}
|
| 228 |
+
</button>
|
| 229 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
</div>
|
| 231 |
|
| 232 |
+
{/* ── Right: Results ──────────────────────────────────── */}
|
| 233 |
+
<div className="lg:col-span-5 flex flex-col">
|
| 234 |
+
<div className="glass-panel rounded-xl flex flex-col h-full p-xl">
|
| 235 |
+
{/* Card header */}
|
| 236 |
+
<div className="flex justify-between items-center mb-lg pb-md border-b border-white/[0.06]">
|
| 237 |
+
<h3 className="font-mono text-label-md text-on-surface-variant uppercase tracking-wider">
|
| 238 |
+
Analysis Results
|
| 239 |
+
</h3>
|
| 240 |
+
{data && (
|
| 241 |
+
<div className="flex items-center gap-3">
|
| 242 |
+
<span className="flex items-center gap-1 bg-surface-container px-2 py-0.5 rounded border border-white/[0.08]">
|
| 243 |
+
<span className="font-mono text-label-sm text-on-surface-variant">LANG:</span>
|
| 244 |
+
<span className="font-mono text-label-sm text-primary uppercase">{data.detected_language}</span>
|
| 245 |
+
</span>
|
| 246 |
+
<span className="flex items-center gap-1 text-on-surface-variant">
|
| 247 |
+
<MSIcon name="timer" size={14} />
|
| 248 |
+
<span className="font-mono text-label-sm">{data.processing_time_ms?.toFixed(1)}ms</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 249 |
</span>
|
| 250 |
</div>
|
| 251 |
+
)}
|
| 252 |
+
</div>
|
| 253 |
+
|
| 254 |
+
{/* Content states */}
|
| 255 |
+
{!data && !mutation.isPending && <ResultsEmpty />}
|
| 256 |
+
{mutation.isPending && <ResultsLoading />}
|
| 257 |
+
|
| 258 |
+
{data && (
|
| 259 |
+
<div className="flex flex-col gap-lg flex-1 overflow-hidden animate-fade-in">
|
| 260 |
+
{/* Annotated text */}
|
| 261 |
+
<div className="bg-surface border border-white/[0.06] rounded-lg p-md">
|
| 262 |
+
<AnnotatedText text={data.text} aspects={data.aspects} />
|
| 263 |
</div>
|
| 264 |
+
|
| 265 |
+
{/* Legend */}
|
| 266 |
+
<div className="flex items-center gap-4 flex-wrap">
|
| 267 |
+
<span className="flex items-center gap-1.5 font-mono text-label-sm text-on-surface-variant">
|
| 268 |
+
<span className="w-3 h-3 rounded highlight-positive inline-block" /> Positive
|
| 269 |
+
</span>
|
| 270 |
+
<span className="flex items-center gap-1.5 font-mono text-label-sm text-on-surface-variant">
|
| 271 |
+
<span className="w-3 h-3 rounded highlight-negative inline-block" /> Negative
|
| 272 |
+
</span>
|
| 273 |
+
<span className="flex items-center gap-1.5 font-mono text-label-sm text-on-surface-variant">
|
| 274 |
+
<span className="w-3 h-3 rounded highlight-neutral inline-block" /> Neutral
|
| 275 |
+
</span>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
{/* Aspects list */}
|
| 279 |
+
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
|
| 280 |
+
<h4 className="font-mono text-label-sm text-on-surface-variant uppercase tracking-wider mb-2">
|
| 281 |
+
Extracted Aspects
|
| 282 |
+
</h4>
|
| 283 |
+
{data.aspects && data.aspects.length > 0 ? (
|
| 284 |
+
data.aspects.map((asp, i) => <AspectCard key={i} asp={asp} />)
|
| 285 |
+
) : (
|
| 286 |
+
<div className="text-center py-8 rounded-lg border border-dashed border-white/[0.12] text-on-surface-variant text-body-sm">
|
| 287 |
+
No specific aspects detected
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
</div>
|
| 289 |
+
)}
|
| 290 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
</div>
|
| 292 |
+
)}
|
| 293 |
+
</div>
|
| 294 |
</div>
|
| 295 |
</div>
|
| 296 |
)
|
dashboard/src/components/Navbar.jsx
DELETED
|
@@ -1,120 +0,0 @@
|
|
| 1 |
-
import React, { useState, useEffect } from 'react'
|
| 2 |
-
import { Link, useLocation } from 'react-router-dom'
|
| 3 |
-
import { Brain, Moon, Sun, Menu, X, Activity } from 'lucide-react'
|
| 4 |
-
import { api } from '../api/client'
|
| 5 |
-
import { useQuery } from '@tanstack/react-query'
|
| 6 |
-
|
| 7 |
-
export default function Navbar({ toggleMobileMenu, isMobileMenuOpen }) {
|
| 8 |
-
const location = useLocation()
|
| 9 |
-
const [darkMode, setDarkMode] = useState(
|
| 10 |
-
localStorage.getItem('theme') === 'dark' ||
|
| 11 |
-
(!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
| 12 |
-
)
|
| 13 |
-
|
| 14 |
-
useEffect(() => {
|
| 15 |
-
if (darkMode) {
|
| 16 |
-
document.documentElement.classList.add('dark')
|
| 17 |
-
localStorage.setItem('theme', 'dark')
|
| 18 |
-
} else {
|
| 19 |
-
document.documentElement.classList.remove('dark')
|
| 20 |
-
localStorage.setItem('theme', 'light')
|
| 21 |
-
}
|
| 22 |
-
}, [darkMode])
|
| 23 |
-
|
| 24 |
-
const { data: healthData } = useQuery({
|
| 25 |
-
queryKey: ['health'],
|
| 26 |
-
queryFn: api.getHealth,
|
| 27 |
-
refetchInterval: 30000,
|
| 28 |
-
})
|
| 29 |
-
|
| 30 |
-
const isHealthy = healthData?.status === 'ok'
|
| 31 |
-
|
| 32 |
-
const navLinks = [
|
| 33 |
-
{ path: '/predict', label: 'Live Predict' },
|
| 34 |
-
{ path: '/analytics', label: 'Batch Analytics' },
|
| 35 |
-
{ path: '/monitor', label: 'Monitor' }
|
| 36 |
-
]
|
| 37 |
-
|
| 38 |
-
return (
|
| 39 |
-
<nav className="bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
|
| 40 |
-
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
| 41 |
-
<div className="flex justify-between h-16">
|
| 42 |
-
<div className="flex">
|
| 43 |
-
<div className="flex-shrink-0 flex items-center">
|
| 44 |
-
<Link to="/" className="flex items-center gap-2">
|
| 45 |
-
<Brain className="h-8 w-8 text-indigo-600 dark:text-indigo-400" />
|
| 46 |
-
<span className="text-xl font-bold text-gray-900 dark:text-white">SentimentAI</span>
|
| 47 |
-
</Link>
|
| 48 |
-
</div>
|
| 49 |
-
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
| 50 |
-
{navLinks.map((link) => (
|
| 51 |
-
<Link
|
| 52 |
-
key={link.path}
|
| 53 |
-
to={link.path}
|
| 54 |
-
className={`inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium ${
|
| 55 |
-
location.pathname === link.path
|
| 56 |
-
? 'border-indigo-500 text-gray-900 dark:text-white'
|
| 57 |
-
: 'border-transparent text-gray-500 dark:text-gray-300 hover:border-gray-300 hover:text-gray-700 dark:hover:text-gray-100'
|
| 58 |
-
}`}
|
| 59 |
-
>
|
| 60 |
-
{link.label}
|
| 61 |
-
</Link>
|
| 62 |
-
))}
|
| 63 |
-
</div>
|
| 64 |
-
</div>
|
| 65 |
-
<div className="flex items-center gap-4">
|
| 66 |
-
<div className="hidden sm:flex items-center gap-2 px-3 py-1 rounded-full bg-gray-100 dark:bg-slate-700">
|
| 67 |
-
<div className={`h-2 w-2 rounded-full ${isHealthy ? 'bg-green-500' : 'bg-red-500 animate-pulse'}`}></div>
|
| 68 |
-
<span className="text-xs font-medium text-gray-700 dark:text-gray-200">
|
| 69 |
-
API {isHealthy ? 'Online' : 'Offline'}
|
| 70 |
-
</span>
|
| 71 |
-
</div>
|
| 72 |
-
|
| 73 |
-
<button
|
| 74 |
-
onClick={() => setDarkMode(!darkMode)}
|
| 75 |
-
className="p-2 rounded-lg text-gray-500 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-slate-700 transition-colors"
|
| 76 |
-
>
|
| 77 |
-
{darkMode ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
|
| 78 |
-
</button>
|
| 79 |
-
|
| 80 |
-
<div className="flex items-center sm:hidden">
|
| 81 |
-
<button
|
| 82 |
-
onClick={toggleMobileMenu}
|
| 83 |
-
className="inline-flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 dark:hover:bg-slate-700"
|
| 84 |
-
>
|
| 85 |
-
{isMobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
| 86 |
-
</button>
|
| 87 |
-
</div>
|
| 88 |
-
</div>
|
| 89 |
-
</div>
|
| 90 |
-
</div>
|
| 91 |
-
|
| 92 |
-
{isMobileMenuOpen && (
|
| 93 |
-
<div className="sm:hidden border-t border-gray-200 dark:border-slate-700">
|
| 94 |
-
<div className="pt-2 pb-3 space-y-1">
|
| 95 |
-
{navLinks.map((link) => (
|
| 96 |
-
<Link
|
| 97 |
-
key={link.path}
|
| 98 |
-
to={link.path}
|
| 99 |
-
className={`block pl-3 pr-4 py-2 border-l-4 text-base font-medium ${
|
| 100 |
-
location.pathname === link.path
|
| 101 |
-
? 'bg-indigo-50 dark:bg-indigo-900/50 border-indigo-500 text-indigo-700 dark:text-indigo-200'
|
| 102 |
-
: 'border-transparent text-gray-500 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 hover:border-gray-300 hover:text-gray-700'
|
| 103 |
-
}`}
|
| 104 |
-
onClick={toggleMobileMenu}
|
| 105 |
-
>
|
| 106 |
-
{link.label}
|
| 107 |
-
</Link>
|
| 108 |
-
))}
|
| 109 |
-
<div className="pl-3 pr-4 py-2 flex items-center gap-2">
|
| 110 |
-
<div className={`h-2 w-2 rounded-full ${isHealthy ? 'bg-green-500' : 'bg-red-500 animate-pulse'}`}></div>
|
| 111 |
-
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
| 112 |
-
API {isHealthy ? 'Online' : 'Offline'}
|
| 113 |
-
</span>
|
| 114 |
-
</div>
|
| 115 |
-
</div>
|
| 116 |
-
</div>
|
| 117 |
-
)}
|
| 118 |
-
</nav>
|
| 119 |
-
)
|
| 120 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
dashboard/src/components/Sidebar.jsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState } from 'react'
|
| 2 |
+
import { Link, useLocation } from 'react-router-dom'
|
| 3 |
+
import { useQuery } from '@tanstack/react-query'
|
| 4 |
+
import { api } from '../api/client'
|
| 5 |
+
|
| 6 |
+
const NAV = [
|
| 7 |
+
{ path: '/predict', icon: 'psychology', label: 'Predictor' },
|
| 8 |
+
{ path: '/batch', icon: 'cloud_upload', label: 'Batch Analytics' },
|
| 9 |
+
{ path: '/monitor', icon: 'monitoring', label: 'System Health' },
|
| 10 |
+
]
|
| 11 |
+
|
| 12 |
+
function MSIcon({ name, filled = false, size = 20, className = '' }) {
|
| 13 |
+
return (
|
| 14 |
+
<span
|
| 15 |
+
className={`material-symbols-outlined ${className}`}
|
| 16 |
+
style={{
|
| 17 |
+
fontSize: size,
|
| 18 |
+
fontVariationSettings: filled ? `'FILL' 1, 'wght' 400` : `'FILL' 0, 'wght' 300`,
|
| 19 |
+
}}
|
| 20 |
+
>
|
| 21 |
+
{name}
|
| 22 |
+
</span>
|
| 23 |
+
)
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export { MSIcon }
|
| 27 |
+
|
| 28 |
+
export default function Sidebar({ isOpen, onClose }) {
|
| 29 |
+
const location = useLocation()
|
| 30 |
+
|
| 31 |
+
const { data: health } = useQuery({
|
| 32 |
+
queryKey: ['health'],
|
| 33 |
+
queryFn: api.getHealth,
|
| 34 |
+
refetchInterval: 30000,
|
| 35 |
+
})
|
| 36 |
+
const isHealthy = health?.status === 'ok'
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<>
|
| 40 |
+
{/* Mobile overlay */}
|
| 41 |
+
{isOpen && (
|
| 42 |
+
<div
|
| 43 |
+
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 md:hidden"
|
| 44 |
+
onClick={onClose}
|
| 45 |
+
aria-hidden="true"
|
| 46 |
+
/>
|
| 47 |
+
)}
|
| 48 |
+
|
| 49 |
+
{/* Sidebar panel */}
|
| 50 |
+
<nav
|
| 51 |
+
aria-label="Main navigation"
|
| 52 |
+
className={`
|
| 53 |
+
fixed left-0 top-0 h-screen w-64 z-50 flex flex-col
|
| 54 |
+
bg-surface-container border-r border-white/[0.07]
|
| 55 |
+
transition-transform duration-250 ease-out
|
| 56 |
+
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
| 57 |
+
md:translate-x-0
|
| 58 |
+
`}
|
| 59 |
+
>
|
| 60 |
+
{/* Logo */}
|
| 61 |
+
<div className="flex items-center gap-3 px-xl py-xl">
|
| 62 |
+
<MSIcon name="psychology" filled size={28} className="text-primary" />
|
| 63 |
+
<div>
|
| 64 |
+
<h1 className="text-title-lg font-semibold text-primary leading-tight">SentimentAI</h1>
|
| 65 |
+
<p className="font-mono text-label-sm text-on-surface-variant">Analysis Engine v2.4</p>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
|
| 69 |
+
{/* CTA */}
|
| 70 |
+
<div className="px-xl mb-xl">
|
| 71 |
+
<Link
|
| 72 |
+
to="/predict"
|
| 73 |
+
onClick={onClose}
|
| 74 |
+
className="btn-primary w-full text-sm"
|
| 75 |
+
>
|
| 76 |
+
<MSIcon name="add" size={18} />
|
| 77 |
+
New Analysis
|
| 78 |
+
</Link>
|
| 79 |
+
</div>
|
| 80 |
+
|
| 81 |
+
{/* Nav links */}
|
| 82 |
+
<ul className="flex-1 px-sm space-y-0.5 overflow-y-auto">
|
| 83 |
+
{NAV.map(({ path, icon, label }) => {
|
| 84 |
+
const active = location.pathname === path
|
| 85 |
+
return (
|
| 86 |
+
<li key={path}>
|
| 87 |
+
<Link
|
| 88 |
+
to={path}
|
| 89 |
+
onClick={onClose}
|
| 90 |
+
className={active ? 'nav-item-active' : 'nav-item'}
|
| 91 |
+
aria-current={active ? 'page' : undefined}
|
| 92 |
+
>
|
| 93 |
+
<MSIcon name={icon} filled={active} size={20} />
|
| 94 |
+
<span>{label}</span>
|
| 95 |
+
</Link>
|
| 96 |
+
</li>
|
| 97 |
+
)
|
| 98 |
+
})}
|
| 99 |
+
</ul>
|
| 100 |
+
|
| 101 |
+
{/* Bottom section */}
|
| 102 |
+
<div className="px-sm pt-sm pb-xl border-t border-white/[0.06] space-y-0.5 mt-auto">
|
| 103 |
+
{/* API health pill */}
|
| 104 |
+
<div className="flex items-center gap-2 px-3 py-2 mb-1">
|
| 105 |
+
<span
|
| 106 |
+
className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
| 107 |
+
isHealthy ? 'bg-tertiary shadow-glow-positive' : 'bg-error shadow-glow-negative animate-pulse'
|
| 108 |
+
}`}
|
| 109 |
+
/>
|
| 110 |
+
<span className="font-mono text-label-md text-on-surface-variant">
|
| 111 |
+
API {isHealthy ? 'Online' : 'Offline'}
|
| 112 |
+
</span>
|
| 113 |
+
</div>
|
| 114 |
+
|
| 115 |
+
<Link to="/monitor" onClick={onClose} className="nav-item">
|
| 116 |
+
<MSIcon name="settings" size={20} />
|
| 117 |
+
<span>Settings</span>
|
| 118 |
+
</Link>
|
| 119 |
+
<a
|
| 120 |
+
href="http://localhost:8000/docs"
|
| 121 |
+
target="_blank"
|
| 122 |
+
rel="noopener noreferrer"
|
| 123 |
+
className="nav-item"
|
| 124 |
+
>
|
| 125 |
+
<MSIcon name="menu_book" size={20} />
|
| 126 |
+
<span>API Docs</span>
|
| 127 |
+
</a>
|
| 128 |
+
</div>
|
| 129 |
+
</nav>
|
| 130 |
+
</>
|
| 131 |
+
)
|
| 132 |
+
}
|
dashboard/src/index.css
CHANGED
|
@@ -2,11 +2,144 @@
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
}
|
|
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
+
/* ── Base ───────────────────────────────────────────────────────────────── */
|
| 6 |
+
html {
|
| 7 |
+
scroll-behavior: smooth;
|
| 8 |
+
-webkit-font-smoothing: antialiased;
|
| 9 |
+
-moz-osx-font-smoothing: grayscale;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
body {
|
| 13 |
+
background-color: #0b1326;
|
| 14 |
+
color: #dae2fd;
|
| 15 |
+
font-family: 'Geist', ui-sans-serif, system-ui, sans-serif;
|
| 16 |
+
min-height: 100vh;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/* Always dark — no light mode flicker */
|
| 20 |
+
html { color-scheme: dark; }
|
| 21 |
+
|
| 22 |
+
/* ── Material Symbols ───────────────────────────────────────────────────── */
|
| 23 |
+
.material-symbols-outlined {
|
| 24 |
+
font-family: 'Material Symbols Outlined';
|
| 25 |
+
font-weight: normal;
|
| 26 |
+
font-style: normal;
|
| 27 |
+
font-size: 20px;
|
| 28 |
+
line-height: 1;
|
| 29 |
+
letter-spacing: normal;
|
| 30 |
+
text-transform: none;
|
| 31 |
+
display: inline-block;
|
| 32 |
+
white-space: nowrap;
|
| 33 |
+
word-wrap: normal;
|
| 34 |
+
direction: ltr;
|
| 35 |
+
-webkit-font-smoothing: antialiased;
|
| 36 |
+
user-select: none;
|
| 37 |
+
vertical-align: middle;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
/* ── Scrollbar ──────────────────────────────────────────────────────────── */
|
| 41 |
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
| 42 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 43 |
+
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.12); border-radius: 3px; }
|
| 44 |
+
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.22); }
|
| 45 |
+
|
| 46 |
+
/* ── Glass panel ────────────────────────────────────────────────────────── */
|
| 47 |
+
@layer components {
|
| 48 |
+
.glass-panel {
|
| 49 |
+
@apply bg-surface-container/75 backdrop-blur-glass border border-white/[0.06];
|
| 50 |
}
|
| 51 |
+
|
| 52 |
+
/* Sidebar nav item */
|
| 53 |
+
.nav-item {
|
| 54 |
+
@apply flex items-center gap-3 px-3 py-2.5 rounded-lg text-on-surface-variant
|
| 55 |
+
text-body-md font-medium transition-colors duration-150 cursor-pointer
|
| 56 |
+
hover:bg-white/[0.05] hover:text-on-surface;
|
| 57 |
}
|
| 58 |
+
.nav-item-active {
|
| 59 |
+
@apply nav-item text-primary bg-white/[0.07] border-r-2 border-primary
|
| 60 |
+
font-semibold;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/* Badge variants */
|
| 64 |
+
.badge-positive {
|
| 65 |
+
@apply inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full
|
| 66 |
+
bg-tertiary/10 text-tertiary border border-tertiary/25
|
| 67 |
+
font-mono text-label-md uppercase tracking-wider;
|
| 68 |
+
}
|
| 69 |
+
.badge-negative {
|
| 70 |
+
@apply inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full
|
| 71 |
+
bg-error/10 text-error border border-error/25
|
| 72 |
+
font-mono text-label-md uppercase tracking-wider;
|
| 73 |
+
}
|
| 74 |
+
.badge-neutral {
|
| 75 |
+
@apply inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full
|
| 76 |
+
bg-outline/10 text-on-surface-variant border border-outline/25
|
| 77 |
+
font-mono text-label-md uppercase tracking-wider;
|
| 78 |
+
}
|
| 79 |
+
.badge-processing {
|
| 80 |
+
@apply inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full
|
| 81 |
+
bg-primary/10 text-primary border border-primary/25
|
| 82 |
+
font-mono text-label-md uppercase tracking-wider animate-pulse;
|
| 83 |
+
}
|
| 84 |
+
.badge-error {
|
| 85 |
+
@apply inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full
|
| 86 |
+
bg-error/10 text-error border border-error/25
|
| 87 |
+
font-mono text-label-md uppercase tracking-wider;
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
/* Highlight in annotated text */
|
| 91 |
+
.highlight-positive {
|
| 92 |
+
@apply bg-tertiary/15 text-tertiary border border-tertiary/30
|
| 93 |
+
rounded px-1 mx-0.5 font-medium;
|
| 94 |
+
}
|
| 95 |
+
.highlight-negative {
|
| 96 |
+
@apply bg-error/15 text-error border border-error/30
|
| 97 |
+
rounded px-1 mx-0.5 font-medium;
|
| 98 |
+
}
|
| 99 |
+
.highlight-neutral {
|
| 100 |
+
@apply bg-outline/15 text-on-surface-variant border border-outline/25
|
| 101 |
+
rounded px-1 mx-0.5 font-medium;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/* Section card */
|
| 105 |
+
.card {
|
| 106 |
+
@apply bg-surface-container rounded-xl border border-white/[0.08] p-xl;
|
| 107 |
+
}
|
| 108 |
+
.card-low {
|
| 109 |
+
@apply bg-surface-container-low rounded-xl border border-white/[0.06] p-xl;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
/* Input */
|
| 113 |
+
.input-base {
|
| 114 |
+
@apply bg-surface border border-white/[0.12] rounded-lg px-3 py-2
|
| 115 |
+
text-body-md text-on-surface placeholder:text-on-surface-variant/60
|
| 116 |
+
focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/40
|
| 117 |
+
transition-colors duration-150;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/* Button primary */
|
| 121 |
+
.btn-primary {
|
| 122 |
+
@apply inline-flex items-center justify-center gap-2
|
| 123 |
+
bg-primary text-on-primary font-mono text-label-md tracking-wider
|
| 124 |
+
px-5 py-2.5 rounded-lg
|
| 125 |
+
hover:brightness-110 active:scale-[0.98]
|
| 126 |
+
transition-all duration-150 cursor-pointer
|
| 127 |
+
disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100;
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/* Drag active state */
|
| 131 |
+
.drag-active {
|
| 132 |
+
@apply border-primary/70 bg-primary/[0.04];
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
/* Stat card */
|
| 136 |
+
.stat-card {
|
| 137 |
+
@apply card relative overflow-hidden;
|
| 138 |
+
}
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/* ── Focus ring ─────────────────────────────────────────────────────────── */
|
| 142 |
+
:focus-visible {
|
| 143 |
+
outline: 2px solid #c0c1ff;
|
| 144 |
+
outline-offset: 2px;
|
| 145 |
}
|
dashboard/src/pages/Analytics.jsx
CHANGED
|
@@ -1,29 +1,67 @@
|
|
| 1 |
import React, { useState, useCallback, useEffect } from 'react'
|
| 2 |
-
import { useDropzone } from 'react-dropzone'
|
| 3 |
import { useMutation, useQuery } from '@tanstack/react-query'
|
| 4 |
-
import {
|
| 5 |
-
import { UploadCloud, File, AlertCircle, Loader2, Download } from 'lucide-react'
|
| 6 |
import toast from 'react-hot-toast'
|
| 7 |
-
import
|
|
|
|
| 8 |
import AspectHeatmap from '../components/AspectHeatmap'
|
| 9 |
import LanguagePie from '../components/LanguagePie'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
|
|
|
| 11 |
export default function Analytics() {
|
| 12 |
const [file, setFile] = useState(null)
|
| 13 |
const [jobId, setJobId] = useState(null)
|
| 14 |
const [isPolling, setIsPolling] = useState(false)
|
| 15 |
|
| 16 |
-
// Upload
|
| 17 |
const uploadMutation = useMutation({
|
| 18 |
mutationFn: (f) => api.uploadBatch(f),
|
| 19 |
onSuccess: (data) => {
|
| 20 |
setJobId(data.job_id)
|
| 21 |
setIsPolling(true)
|
| 22 |
-
toast.success(
|
| 23 |
-
}
|
| 24 |
})
|
| 25 |
|
| 26 |
-
// Poll
|
| 27 |
const { data: jobStatus } = useQuery({
|
| 28 |
queryKey: ['batchStatus', jobId],
|
| 29 |
queryFn: () => api.getBatchStatus(jobId),
|
|
@@ -34,170 +72,210 @@ export default function Analytics() {
|
|
| 34 |
useEffect(() => {
|
| 35 |
if (jobStatus?.status === 'completed' || jobStatus?.status === 'failed') {
|
| 36 |
setIsPolling(false)
|
| 37 |
-
if (jobStatus.status === 'completed')
|
| 38 |
-
|
| 39 |
-
} else {
|
| 40 |
-
toast.error("Batch processing failed")
|
| 41 |
-
}
|
| 42 |
}
|
| 43 |
}, [jobStatus])
|
| 44 |
|
| 45 |
const onDrop = useCallback((acceptedFiles) => {
|
| 46 |
if (acceptedFiles?.length > 0) {
|
| 47 |
-
const
|
| 48 |
-
if (!
|
| 49 |
-
|
| 50 |
-
return
|
| 51 |
-
}
|
| 52 |
-
setFile(selectedFile)
|
| 53 |
}
|
| 54 |
}, [])
|
| 55 |
|
| 56 |
-
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
| 57 |
onDrop,
|
| 58 |
accept: { 'text/csv': ['.csv'] },
|
| 59 |
-
maxFiles: 1
|
| 60 |
})
|
| 61 |
|
| 62 |
-
const handleUpload = () => {
|
| 63 |
-
|
| 64 |
-
uploadMutation.mutate(file)
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
const resetUpload = () => {
|
| 68 |
-
setFile(null)
|
| 69 |
-
setJobId(null)
|
| 70 |
-
setIsPolling(false)
|
| 71 |
-
}
|
| 72 |
|
| 73 |
-
const progress = jobStatus
|
|
|
|
|
|
|
| 74 |
|
| 75 |
return (
|
| 76 |
-
<div className="space-y-
|
|
|
|
| 77 |
<div>
|
| 78 |
-
<h1 className="text-
|
| 79 |
-
<p className="mt-1 text-
|
| 80 |
Upload a CSV of reviews for bulk aspect-based sentiment analysis.
|
| 81 |
</p>
|
| 82 |
</div>
|
| 83 |
|
| 84 |
-
{/* Upload
|
| 85 |
{!jobId && (
|
| 86 |
-
<
|
| 87 |
-
<div
|
| 88 |
-
{...getRootProps()}
|
| 89 |
-
className={`border-2 border-dashed rounded-xl p-12
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
| 94 |
>
|
| 95 |
<input {...getInputProps()} />
|
| 96 |
-
<
|
| 97 |
-
|
| 98 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
</h3>
|
| 100 |
-
<p className="text-
|
| 101 |
-
Must contain a
|
|
|
|
| 102 |
</p>
|
| 103 |
</div>
|
| 104 |
|
|
|
|
| 105 |
{file && (
|
| 106 |
-
<div className="mt-
|
|
|
|
|
|
|
| 107 |
<div className="flex items-center gap-3">
|
| 108 |
-
<
|
| 109 |
<div>
|
| 110 |
-
<p className="text-
|
| 111 |
-
<p className="
|
|
|
|
|
|
|
| 112 |
</div>
|
| 113 |
</div>
|
| 114 |
-
<div className="flex gap-
|
| 115 |
-
<button
|
| 116 |
-
onClick={(e) => { e.stopPropagation(); setFile(null)
|
| 117 |
-
className="px-3 py-1.5 text-sm
|
|
|
|
| 118 |
>
|
| 119 |
Remove
|
| 120 |
</button>
|
| 121 |
-
<button
|
| 122 |
-
onClick={(e) => { e.stopPropagation(); handleUpload()
|
| 123 |
disabled={uploadMutation.isPending}
|
| 124 |
-
className="
|
| 125 |
>
|
| 126 |
-
{uploadMutation.isPending ?
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
Process File
|
| 128 |
</button>
|
| 129 |
</div>
|
| 130 |
</div>
|
| 131 |
)}
|
| 132 |
-
</
|
| 133 |
)}
|
| 134 |
|
| 135 |
-
{/*
|
| 136 |
{jobId && (
|
| 137 |
-
<
|
| 138 |
-
<div className="flex justify-between items-
|
| 139 |
<div>
|
| 140 |
-
<
|
| 141 |
-
{jobStatus?.status
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
Job Status: <span className="capitalize">{jobStatus?.status || 'Queued'}</span>
|
| 146 |
-
</h3>
|
| 147 |
-
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">ID: {jobId}</p>
|
| 148 |
-
</div>
|
| 149 |
-
|
| 150 |
-
{jobStatus?.status === 'completed' && (
|
| 151 |
-
<div className="flex gap-3">
|
| 152 |
-
<button
|
| 153 |
-
onClick={resetUpload}
|
| 154 |
-
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-600 rounded-md hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors"
|
| 155 |
-
>
|
| 156 |
-
Upload New
|
| 157 |
-
</button>
|
| 158 |
-
{jobStatus?.result_url && (
|
| 159 |
-
<a
|
| 160 |
-
href={`http://localhost:8000${jobStatus.result_url}`}
|
| 161 |
-
download
|
| 162 |
-
className="flex items-center px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-md hover:bg-indigo-700 transition-colors"
|
| 163 |
-
>
|
| 164 |
-
<Download className="h-4 w-4 mr-2" />
|
| 165 |
-
Download Results CSV
|
| 166 |
-
</a>
|
| 167 |
-
)}
|
| 168 |
</div>
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
</div>
|
| 171 |
-
|
|
|
|
| 172 |
<div className="space-y-2">
|
| 173 |
-
<div className="flex justify-between
|
| 174 |
<span>Progress</span>
|
| 175 |
-
<span>
|
|
|
|
|
|
|
|
|
|
| 176 |
</div>
|
| 177 |
-
<div className="w-full
|
| 178 |
-
<div
|
| 179 |
-
className={`h-full rounded-full transition-all duration-500
|
| 180 |
-
jobStatus?.status === 'failed' ? 'bg-
|
| 181 |
}`}
|
| 182 |
style={{ width: `${progress}%` }}
|
| 183 |
-
|
| 184 |
</div>
|
| 185 |
</div>
|
| 186 |
-
</
|
| 187 |
)}
|
| 188 |
|
| 189 |
-
{/*
|
| 190 |
-
{
|
| 191 |
-
<
|
| 192 |
-
<
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
</div>
|
| 202 |
)}
|
| 203 |
</div>
|
|
|
|
| 1 |
import React, { useState, useCallback, useEffect } from 'react'
|
|
|
|
| 2 |
import { useMutation, useQuery } from '@tanstack/react-query'
|
| 3 |
+
import { useDropzone } from 'react-dropzone'
|
|
|
|
| 4 |
import toast from 'react-hot-toast'
|
| 5 |
+
import { api } from '../api/client'
|
| 6 |
+
import { MSIcon } from '../components/Sidebar'
|
| 7 |
import AspectHeatmap from '../components/AspectHeatmap'
|
| 8 |
import LanguagePie from '../components/LanguagePie'
|
| 9 |
+
import SentimentChart from '../components/SentimentChart'
|
| 10 |
+
import { API_URL } from '../config'
|
| 11 |
+
|
| 12 |
+
// ── Status badge ──────────────────────────────────────────────────────────────
|
| 13 |
+
function StatusBadge({ status }) {
|
| 14 |
+
switch (status) {
|
| 15 |
+
case 'completed':
|
| 16 |
+
return (
|
| 17 |
+
<span className="badge-positive">
|
| 18 |
+
<span className="w-1.5 h-1.5 rounded-full bg-tertiary flex-shrink-0" />
|
| 19 |
+
Completed
|
| 20 |
+
</span>
|
| 21 |
+
)
|
| 22 |
+
case 'processing':
|
| 23 |
+
return (
|
| 24 |
+
<span className="badge-processing">
|
| 25 |
+
<span className="w-1.5 h-1.5 rounded-full bg-primary flex-shrink-0" />
|
| 26 |
+
Processing
|
| 27 |
+
</span>
|
| 28 |
+
)
|
| 29 |
+
case 'queued':
|
| 30 |
+
return (
|
| 31 |
+
<span className="badge-neutral">
|
| 32 |
+
<span className="w-1.5 h-1.5 rounded-full bg-outline flex-shrink-0" />
|
| 33 |
+
Queued
|
| 34 |
+
</span>
|
| 35 |
+
)
|
| 36 |
+
case 'failed':
|
| 37 |
+
return (
|
| 38 |
+
<span className="badge-error">
|
| 39 |
+
<span className="w-1.5 h-1.5 rounded-full bg-error flex-shrink-0" />
|
| 40 |
+
Failed
|
| 41 |
+
</span>
|
| 42 |
+
)
|
| 43 |
+
default:
|
| 44 |
+
return <span className="badge-neutral">{status}</span>
|
| 45 |
+
}
|
| 46 |
+
}
|
| 47 |
|
| 48 |
+
// ── Main page ─────────────────────────────────────────────────────────────────
|
| 49 |
export default function Analytics() {
|
| 50 |
const [file, setFile] = useState(null)
|
| 51 |
const [jobId, setJobId] = useState(null)
|
| 52 |
const [isPolling, setIsPolling] = useState(false)
|
| 53 |
|
| 54 |
+
// Upload mutation — unchanged API call
|
| 55 |
const uploadMutation = useMutation({
|
| 56 |
mutationFn: (f) => api.uploadBatch(f),
|
| 57 |
onSuccess: (data) => {
|
| 58 |
setJobId(data.job_id)
|
| 59 |
setIsPolling(true)
|
| 60 |
+
toast.success('Batch job queued successfully')
|
| 61 |
+
},
|
| 62 |
})
|
| 63 |
|
| 64 |
+
// Poll job status — unchanged API call
|
| 65 |
const { data: jobStatus } = useQuery({
|
| 66 |
queryKey: ['batchStatus', jobId],
|
| 67 |
queryFn: () => api.getBatchStatus(jobId),
|
|
|
|
| 72 |
useEffect(() => {
|
| 73 |
if (jobStatus?.status === 'completed' || jobStatus?.status === 'failed') {
|
| 74 |
setIsPolling(false)
|
| 75 |
+
if (jobStatus.status === 'completed') toast.success('Batch processing completed!')
|
| 76 |
+
else toast.error('Batch processing failed')
|
|
|
|
|
|
|
|
|
|
| 77 |
}
|
| 78 |
}, [jobStatus])
|
| 79 |
|
| 80 |
const onDrop = useCallback((acceptedFiles) => {
|
| 81 |
if (acceptedFiles?.length > 0) {
|
| 82 |
+
const f = acceptedFiles[0]
|
| 83 |
+
if (!f.name.endsWith('.csv')) { toast.error('Please upload a CSV file'); return }
|
| 84 |
+
setFile(f)
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
}, [])
|
| 87 |
|
| 88 |
+
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
| 89 |
onDrop,
|
| 90 |
accept: { 'text/csv': ['.csv'] },
|
| 91 |
+
maxFiles: 1,
|
| 92 |
})
|
| 93 |
|
| 94 |
+
const handleUpload = () => { if (file) uploadMutation.mutate(file) }
|
| 95 |
+
const resetUpload = () => { setFile(null); setJobId(null); setIsPolling(false) }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
|
| 97 |
+
const progress = jobStatus
|
| 98 |
+
? Math.min(100, Math.round((jobStatus.processed / jobStatus.total_reviews) * 100))
|
| 99 |
+
: 0
|
| 100 |
|
| 101 |
return (
|
| 102 |
+
<div className="space-y-xl">
|
| 103 |
+
{/* Page header */}
|
| 104 |
<div>
|
| 105 |
+
<h1 className="text-headline-md text-on-surface">Batch Analytics</h1>
|
| 106 |
+
<p className="mt-1 text-body-md text-on-surface-variant">
|
| 107 |
Upload a CSV of reviews for bulk aspect-based sentiment analysis.
|
| 108 |
</p>
|
| 109 |
</div>
|
| 110 |
|
| 111 |
+
{/* ── Upload zone (hidden when job is active) ── */}
|
| 112 |
{!jobId && (
|
| 113 |
+
<section className="card">
|
| 114 |
+
<div
|
| 115 |
+
{...getRootProps()}
|
| 116 |
+
className={`border-2 border-dashed rounded-xl p-12 flex flex-col items-center
|
| 117 |
+
justify-center text-center cursor-pointer transition-colors duration-200
|
| 118 |
+
${isDragActive
|
| 119 |
+
? 'drag-active border-primary/60'
|
| 120 |
+
: 'border-white/[0.14] hover:border-primary/40 hover:bg-primary/[0.02]'
|
| 121 |
+
}`}
|
| 122 |
>
|
| 123 |
<input {...getInputProps()} />
|
| 124 |
+
<MSIcon
|
| 125 |
+
name="cloud_upload"
|
| 126 |
+
size={48}
|
| 127 |
+
className="text-on-surface-variant mb-4"
|
| 128 |
+
/>
|
| 129 |
+
<h3 className="text-headline-sm text-on-surface mb-2">
|
| 130 |
+
{isDragActive ? 'Drop the CSV here…' : 'Drag & drop a CSV, or click to select'}
|
| 131 |
</h3>
|
| 132 |
+
<p className="text-body-md text-on-surface-variant max-w-sm">
|
| 133 |
+
Must contain a <code className="font-mono text-primary px-1">text</code> column.
|
| 134 |
+
Maximum 10,000 rows. Files are deleted after analysis.
|
| 135 |
</p>
|
| 136 |
</div>
|
| 137 |
|
| 138 |
+
{/* Selected file row */}
|
| 139 |
{file && (
|
| 140 |
+
<div className="mt-lg flex items-center justify-between
|
| 141 |
+
p-md rounded-lg bg-surface-container-low border border-white/[0.08]
|
| 142 |
+
animate-slide-in">
|
| 143 |
<div className="flex items-center gap-3">
|
| 144 |
+
<MSIcon name="description" size={20} className="text-primary" />
|
| 145 |
<div>
|
| 146 |
+
<p className="text-body-md text-on-surface font-medium">{file.name}</p>
|
| 147 |
+
<p className="font-mono text-label-sm text-on-surface-variant">
|
| 148 |
+
{(file.size / 1024 / 1024).toFixed(2)} MB
|
| 149 |
+
</p>
|
| 150 |
</div>
|
| 151 |
</div>
|
| 152 |
+
<div className="flex gap-2">
|
| 153 |
+
<button
|
| 154 |
+
onClick={(e) => { e.stopPropagation(); setFile(null) }}
|
| 155 |
+
className="px-3 py-1.5 text-body-sm text-on-surface-variant border border-white/[0.12]
|
| 156 |
+
rounded-lg hover:bg-white/[0.05] hover:text-on-surface transition-colors"
|
| 157 |
>
|
| 158 |
Remove
|
| 159 |
</button>
|
| 160 |
+
<button
|
| 161 |
+
onClick={(e) => { e.stopPropagation(); handleUpload() }}
|
| 162 |
disabled={uploadMutation.isPending}
|
| 163 |
+
className="btn-primary text-body-sm"
|
| 164 |
>
|
| 165 |
+
{uploadMutation.isPending ? (
|
| 166 |
+
<span className="material-symbols-outlined animate-spin text-[16px]">progress_activity</span>
|
| 167 |
+
) : (
|
| 168 |
+
<MSIcon name="rocket_launch" size={16} />
|
| 169 |
+
)}
|
| 170 |
Process File
|
| 171 |
</button>
|
| 172 |
</div>
|
| 173 |
</div>
|
| 174 |
)}
|
| 175 |
+
</section>
|
| 176 |
)}
|
| 177 |
|
| 178 |
+
{/* ── Job progress ── */}
|
| 179 |
{jobId && (
|
| 180 |
+
<section className="card animate-fade-in">
|
| 181 |
+
<div className="flex flex-wrap justify-between items-start gap-4 mb-lg">
|
| 182 |
<div>
|
| 183 |
+
<div className="flex items-center gap-2 mb-1">
|
| 184 |
+
<StatusBadge status={jobStatus?.status || 'queued'} />
|
| 185 |
+
<h3 className="text-title-lg text-on-surface">
|
| 186 |
+
{file?.name || 'Batch Job'}
|
| 187 |
+
</h3>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
</div>
|
| 189 |
+
<p className="font-mono text-label-sm text-on-surface-variant">ID: {jobId}</p>
|
| 190 |
+
</div>
|
| 191 |
+
<div className="flex gap-2">
|
| 192 |
+
{jobStatus?.status === 'completed' && (
|
| 193 |
+
<>
|
| 194 |
+
<button onClick={resetUpload} className="px-3 py-1.5 text-body-sm border border-white/[0.12] rounded-lg text-on-surface-variant hover:bg-white/[0.05] transition-colors">
|
| 195 |
+
Upload New
|
| 196 |
+
</button>
|
| 197 |
+
{jobStatus?.result_url && (
|
| 198 |
+
<a
|
| 199 |
+
href={`${API_URL}${jobStatus.result_url}`}
|
| 200 |
+
download
|
| 201 |
+
className="btn-primary text-body-sm"
|
| 202 |
+
>
|
| 203 |
+
<MSIcon name="download" size={16} />
|
| 204 |
+
Download CSV
|
| 205 |
+
</a>
|
| 206 |
+
)}
|
| 207 |
+
</>
|
| 208 |
+
)}
|
| 209 |
+
</div>
|
| 210 |
</div>
|
| 211 |
+
|
| 212 |
+
{/* Progress bar */}
|
| 213 |
<div className="space-y-2">
|
| 214 |
+
<div className="flex justify-between font-mono text-label-sm text-on-surface-variant">
|
| 215 |
<span>Progress</span>
|
| 216 |
+
<span>
|
| 217 |
+
{jobStatus ? `${jobStatus.processed} / ${jobStatus.total_reviews}` : '0'} rows
|
| 218 |
+
({progress}%)
|
| 219 |
+
</span>
|
| 220 |
</div>
|
| 221 |
+
<div className="w-full h-1.5 bg-surface-container-highest rounded-full overflow-hidden">
|
| 222 |
+
<div
|
| 223 |
+
className={`h-full rounded-full transition-all duration-500 ${
|
| 224 |
+
jobStatus?.status === 'failed' ? 'bg-error' : 'bg-primary'
|
| 225 |
}`}
|
| 226 |
style={{ width: `${progress}%` }}
|
| 227 |
+
/>
|
| 228 |
</div>
|
| 229 |
</div>
|
| 230 |
+
</section>
|
| 231 |
)}
|
| 232 |
|
| 233 |
+
{/* ── Recent batches mock table ── */}
|
| 234 |
+
{!jobId && (
|
| 235 |
+
<section>
|
| 236 |
+
<h2 className="text-headline-sm text-on-surface mb-lg">Recent Batches</h2>
|
| 237 |
+
<div className="card overflow-hidden p-0">
|
| 238 |
+
<div className="overflow-x-auto">
|
| 239 |
+
<table className="w-full text-left border-collapse">
|
| 240 |
+
<thead>
|
| 241 |
+
<tr className="border-b border-white/[0.08] bg-surface-container-high/50">
|
| 242 |
+
<th className="font-mono text-label-sm text-on-surface-variant py-3 px-xl">Filename</th>
|
| 243 |
+
<th className="font-mono text-label-sm text-on-surface-variant py-3 px-lg">Rows</th>
|
| 244 |
+
<th className="font-mono text-label-sm text-on-surface-variant py-3 px-lg">Status</th>
|
| 245 |
+
<th className="font-mono text-label-sm text-on-surface-variant py-3 px-xl text-right">Date</th>
|
| 246 |
+
</tr>
|
| 247 |
+
</thead>
|
| 248 |
+
<tbody className="divide-y divide-white/[0.05]">
|
| 249 |
+
{[
|
| 250 |
+
{ name: 'q3_customer_feedback.csv', rows: '4,250', status: 'completed', date: 'Today, 14:32' },
|
| 251 |
+
{ name: 'product_launch_tweets.csv', rows: '8,912', status: 'processing', date: 'Today, 14:15' },
|
| 252 |
+
{ name: 'corrupted_export_09.csv', rows: '—', status: 'failed', date: 'Yesterday' },
|
| 253 |
+
].map((row, i) => (
|
| 254 |
+
<tr key={i} className="hover:bg-white/[0.03] transition-colors">
|
| 255 |
+
<td className="py-3 px-xl">
|
| 256 |
+
<div className="flex items-center gap-2 text-body-md text-on-surface">
|
| 257 |
+
<MSIcon name="description" size={16} className="text-on-surface-variant" />
|
| 258 |
+
{row.name}
|
| 259 |
+
</div>
|
| 260 |
+
</td>
|
| 261 |
+
<td className="py-3 px-lg font-mono text-body-sm text-on-surface-variant">{row.rows}</td>
|
| 262 |
+
<td className="py-3 px-lg"><StatusBadge status={row.status} /></td>
|
| 263 |
+
<td className="py-3 px-xl text-right font-mono text-body-sm text-on-surface-variant">{row.date}</td>
|
| 264 |
+
</tr>
|
| 265 |
+
))}
|
| 266 |
+
</tbody>
|
| 267 |
+
</table>
|
| 268 |
+
</div>
|
| 269 |
</div>
|
| 270 |
+
</section>
|
| 271 |
+
)}
|
| 272 |
+
|
| 273 |
+
{/* ── Charts (post-completion) ── */}
|
| 274 |
+
{jobStatus?.status === 'completed' && (
|
| 275 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-xl animate-fade-in">
|
| 276 |
+
<div className="xl:col-span-2"><AspectHeatmap /></div>
|
| 277 |
+
<div><LanguagePie /></div>
|
| 278 |
+
<div className="lg:col-span-2 xl:col-span-3"><SentimentChart /></div>
|
| 279 |
</div>
|
| 280 |
)}
|
| 281 |
</div>
|
dashboard/src/pages/Monitor.jsx
CHANGED
|
@@ -1,33 +1,111 @@
|
|
| 1 |
import React, { useState } from 'react'
|
| 2 |
import { useQuery } from '@tanstack/react-query'
|
| 3 |
import { api } from '../api/client'
|
| 4 |
-
import {
|
| 5 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
export default function Monitor() {
|
| 7 |
const [refreshInterval, setRefreshInterval] = useState(30000)
|
| 8 |
|
| 9 |
const { data: health, isLoading } = useQuery({
|
| 10 |
queryKey: ['health-monitor'],
|
| 11 |
queryFn: api.getHealth,
|
| 12 |
-
refetchInterval: refreshInterval,
|
| 13 |
})
|
| 14 |
|
|
|
|
|
|
|
| 15 |
return (
|
| 16 |
-
<div className="space-y-
|
| 17 |
-
|
|
|
|
| 18 |
<div>
|
| 19 |
-
<h1 className="text-
|
| 20 |
-
<p className="mt-1 text-
|
| 21 |
Real-time API health, model metadata, and request statistics.
|
| 22 |
</p>
|
| 23 |
</div>
|
| 24 |
-
|
| 25 |
<div className="flex items-center gap-2">
|
| 26 |
-
<label className="
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
| 28 |
value={refreshInterval}
|
| 29 |
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
| 30 |
-
className="
|
| 31 |
>
|
| 32 |
<option value={10000}>10s</option>
|
| 33 |
<option value={30000}>30s</option>
|
|
@@ -37,88 +115,119 @@ export default function Monitor() {
|
|
| 37 |
</div>
|
| 38 |
</div>
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
</div>
|
| 47 |
<div>
|
| 48 |
-
<h2 className="text-lg
|
| 49 |
-
<p className="
|
| 50 |
</div>
|
| 51 |
</div>
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
<span className=
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
</span>
|
| 61 |
</div>
|
| 62 |
</div>
|
| 63 |
|
| 64 |
-
{/* Model
|
| 65 |
-
<div className="
|
| 66 |
-
<div className="flex items-center gap-3 mb-
|
| 67 |
-
<div className="p-
|
| 68 |
-
<
|
| 69 |
</div>
|
| 70 |
<div>
|
| 71 |
-
<h2 className="text-lg
|
| 72 |
-
<p className="
|
| 73 |
</div>
|
| 74 |
</div>
|
| 75 |
-
|
| 76 |
-
<div className="grid grid-cols-2 gap-
|
| 77 |
-
<
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Supported Languages</p>
|
| 83 |
-
<p className="font-medium text-gray-900 dark:text-white">English, Hindi, Hinglish</p>
|
| 84 |
-
</div>
|
| 85 |
-
<div className="bg-gray-50 dark:bg-slate-700/50 p-4 rounded-lg">
|
| 86 |
-
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Aspect Extraction</p>
|
| 87 |
-
<div className="flex items-center gap-1 font-medium text-gray-900 dark:text-white">
|
| 88 |
-
<ShieldCheck className="h-4 w-4 text-green-500" />
|
| 89 |
-
Loaded
|
| 90 |
-
</div>
|
| 91 |
</div>
|
| 92 |
-
<div className="bg-
|
| 93 |
-
<p className="
|
| 94 |
-
<
|
| 95 |
-
<ShieldCheck className="h-4 w-4 text-green-500" />
|
| 96 |
-
Loaded
|
| 97 |
-
</div>
|
| 98 |
</div>
|
| 99 |
</div>
|
| 100 |
</div>
|
| 101 |
</div>
|
| 102 |
|
| 103 |
-
{/*
|
| 104 |
-
<
|
| 105 |
-
|
| 106 |
-
<div className="
|
| 107 |
-
<
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
</div>
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
</div>
|
| 123 |
</div>
|
| 124 |
</div>
|
|
|
|
| 1 |
import React, { useState } from 'react'
|
| 2 |
import { useQuery } from '@tanstack/react-query'
|
| 3 |
import { api } from '../api/client'
|
| 4 |
+
import { MSIcon } from '../components/Sidebar'
|
| 5 |
|
| 6 |
+
// ── Stat card with sparkline area ─────────────────────────────────────────────
|
| 7 |
+
function StatCard({ icon, label, value, sub, sparkColor, positive }) {
|
| 8 |
+
return (
|
| 9 |
+
<div className="stat-card group">
|
| 10 |
+
{/* Sparkline BG */}
|
| 11 |
+
<div
|
| 12 |
+
className="absolute bottom-0 left-0 w-full h-16 opacity-40 group-hover:opacity-70 transition-opacity pointer-events-none"
|
| 13 |
+
style={{
|
| 14 |
+
background: `linear-gradient(180deg, ${sparkColor}33 0%, transparent 100%)`,
|
| 15 |
+
}}
|
| 16 |
+
>
|
| 17 |
+
<svg viewBox="0 0 100 40" className="w-full h-full" preserveAspectRatio="none">
|
| 18 |
+
<path
|
| 19 |
+
d={positive
|
| 20 |
+
? "M0 40 L0 28 Q25 24 50 20 T100 14 L100 40 Z"
|
| 21 |
+
: "M0 40 L0 32 Q25 30 50 26 T100 22 L100 40 Z"}
|
| 22 |
+
fill={`${sparkColor}22`}
|
| 23 |
+
stroke={sparkColor}
|
| 24 |
+
strokeWidth="1.5"
|
| 25 |
+
vectorEffect="non-scaling-stroke"
|
| 26 |
+
/>
|
| 27 |
+
</svg>
|
| 28 |
+
</div>
|
| 29 |
+
|
| 30 |
+
<div className="relative z-10">
|
| 31 |
+
<div className="flex items-center gap-2 mb-lg">
|
| 32 |
+
<span className="material-symbols-outlined text-2xl" style={{ color: sparkColor, fontVariationSettings: `'FILL' 1` }}>
|
| 33 |
+
{icon}
|
| 34 |
+
</span>
|
| 35 |
+
<span className="font-mono text-label-md text-on-surface-variant uppercase tracking-wider">{label}</span>
|
| 36 |
+
</div>
|
| 37 |
+
<p className="text-display text-on-surface font-semibold">{value}</p>
|
| 38 |
+
{sub && <p className="font-mono text-label-sm text-on-surface-variant mt-1">{sub}</p>}
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
)
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
// ── Health status chip ────────────────────────────────────────────────────────
|
| 45 |
+
function HealthChip({ ok }) {
|
| 46 |
+
return ok ? (
|
| 47 |
+
<span className="badge-positive">
|
| 48 |
+
<span className="w-2 h-2 rounded-full bg-tertiary animate-pulse-slow flex-shrink-0" />
|
| 49 |
+
Healthy
|
| 50 |
+
</span>
|
| 51 |
+
) : (
|
| 52 |
+
<span className="badge-error">
|
| 53 |
+
<span className="w-2 h-2 rounded-full bg-error animate-pulse flex-shrink-0" />
|
| 54 |
+
Degraded
|
| 55 |
+
</span>
|
| 56 |
+
)
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
// ── Info row ─────────────────────────────────────────────────────────────────
|
| 60 |
+
function InfoRow({ label, value, valueClass = '' }) {
|
| 61 |
+
return (
|
| 62 |
+
<div className="bg-surface rounded-lg p-md">
|
| 63 |
+
<p className="font-mono text-label-sm text-on-surface-variant mb-1">{label}</p>
|
| 64 |
+
<p className={`text-body-md text-on-surface font-medium ${valueClass}`}>{value}</p>
|
| 65 |
+
</div>
|
| 66 |
+
)
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
function LoadedBadge() {
|
| 70 |
+
return (
|
| 71 |
+
<div className="flex items-center gap-1.5 text-body-md text-on-surface font-medium">
|
| 72 |
+
<MSIcon name="check_circle" size={16} className="text-tertiary" />
|
| 73 |
+
Loaded
|
| 74 |
+
</div>
|
| 75 |
+
)
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
// ── Main page ─────────────────────────────────────────────────────────────────
|
| 79 |
export default function Monitor() {
|
| 80 |
const [refreshInterval, setRefreshInterval] = useState(30000)
|
| 81 |
|
| 82 |
const { data: health, isLoading } = useQuery({
|
| 83 |
queryKey: ['health-monitor'],
|
| 84 |
queryFn: api.getHealth,
|
| 85 |
+
refetchInterval: refreshInterval || false,
|
| 86 |
})
|
| 87 |
|
| 88 |
+
const isHealthy = health?.status === 'ok'
|
| 89 |
+
|
| 90 |
return (
|
| 91 |
+
<div className="space-y-xl">
|
| 92 |
+
{/* Page header */}
|
| 93 |
+
<div className="flex flex-wrap justify-between items-end gap-4">
|
| 94 |
<div>
|
| 95 |
+
<h1 className="text-headline-md text-on-surface">System Monitor</h1>
|
| 96 |
+
<p className="mt-1 text-body-md text-on-surface-variant">
|
| 97 |
Real-time API health, model metadata, and request statistics.
|
| 98 |
</p>
|
| 99 |
</div>
|
|
|
|
| 100 |
<div className="flex items-center gap-2">
|
| 101 |
+
<label htmlFor="refresh-select" className="font-mono text-label-sm text-on-surface-variant">
|
| 102 |
+
Auto-refresh
|
| 103 |
+
</label>
|
| 104 |
+
<select
|
| 105 |
+
id="refresh-select"
|
| 106 |
value={refreshInterval}
|
| 107 |
onChange={(e) => setRefreshInterval(Number(e.target.value))}
|
| 108 |
+
className="input-base py-1 text-body-sm"
|
| 109 |
>
|
| 110 |
<option value={10000}>10s</option>
|
| 111 |
<option value={30000}>30s</option>
|
|
|
|
| 115 |
</div>
|
| 116 |
</div>
|
| 117 |
|
| 118 |
+
{/* ── Health + Model config ── */}
|
| 119 |
+
<div className="grid grid-cols-1 lg:grid-cols-12 gap-xl">
|
| 120 |
+
{/* API Status */}
|
| 121 |
+
<div className="lg:col-span-4 card flex flex-col gap-lg">
|
| 122 |
+
<div className="flex items-center gap-3">
|
| 123 |
+
<div className={`p-2.5 rounded-lg ${isHealthy ? 'bg-tertiary/10' : 'bg-error/10'}`}>
|
| 124 |
+
<MSIcon
|
| 125 |
+
name="monitor_heart"
|
| 126 |
+
size={24}
|
| 127 |
+
className={isHealthy ? 'text-tertiary' : 'text-error'}
|
| 128 |
+
/>
|
| 129 |
</div>
|
| 130 |
<div>
|
| 131 |
+
<h2 className="text-title-lg text-on-surface">API Status</h2>
|
| 132 |
+
<p className="font-mono text-label-sm text-on-surface-variant">Core Inference Engine</p>
|
| 133 |
</div>
|
| 134 |
</div>
|
| 135 |
+
|
| 136 |
+
<div className="flex items-center gap-3 pt-lg border-t border-white/[0.06]">
|
| 137 |
+
<span className="text-body-md text-on-surface-variant">Current state:</span>
|
| 138 |
+
{isLoading ? (
|
| 139 |
+
<span className="badge-neutral animate-pulse">Checking…</span>
|
| 140 |
+
) : (
|
| 141 |
+
<HealthChip ok={isHealthy} />
|
| 142 |
+
)}
|
|
|
|
| 143 |
</div>
|
| 144 |
</div>
|
| 145 |
|
| 146 |
+
{/* Model Configuration */}
|
| 147 |
+
<div className="lg:col-span-8 card">
|
| 148 |
+
<div className="flex items-center gap-3 mb-xl">
|
| 149 |
+
<div className="p-2.5 rounded-lg bg-primary/10">
|
| 150 |
+
<MSIcon name="memory" size={24} className="text-primary" />
|
| 151 |
</div>
|
| 152 |
<div>
|
| 153 |
+
<h2 className="text-title-lg text-on-surface">Model Configuration</h2>
|
| 154 |
+
<p className="font-mono text-label-sm text-on-surface-variant">Loaded ONNX Graphs</p>
|
| 155 |
</div>
|
| 156 |
</div>
|
| 157 |
+
|
| 158 |
+
<div className="grid grid-cols-2 gap-md">
|
| 159 |
+
<InfoRow label="Architecture" value="XLM-RoBERTa (INT8)" />
|
| 160 |
+
<InfoRow label="Supported Languages" value="English, Hindi, Hinglish" />
|
| 161 |
+
<div className="bg-surface rounded-lg p-md">
|
| 162 |
+
<p className="font-mono text-label-sm text-on-surface-variant mb-1">Aspect Extraction</p>
|
| 163 |
+
<LoadedBadge />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
</div>
|
| 165 |
+
<div className="bg-surface rounded-lg p-md">
|
| 166 |
+
<p className="font-mono text-label-sm text-on-surface-variant mb-1">Sentiment Classification</p>
|
| 167 |
+
<LoadedBadge />
|
|
|
|
|
|
|
|
|
|
| 168 |
</div>
|
| 169 |
</div>
|
| 170 |
</div>
|
| 171 |
</div>
|
| 172 |
|
| 173 |
+
{/* ── Performance metrics ── */}
|
| 174 |
+
<div>
|
| 175 |
+
<h2 className="text-headline-sm text-on-surface mb-lg">Performance Metrics</h2>
|
| 176 |
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-xl">
|
| 177 |
+
<StatCard
|
| 178 |
+
icon="database"
|
| 179 |
+
label="Total Requests Today"
|
| 180 |
+
value="12.4k"
|
| 181 |
+
sub="↑ 8% vs yesterday"
|
| 182 |
+
sparkColor="#c0c1ff"
|
| 183 |
+
positive
|
| 184 |
+
/>
|
| 185 |
+
<StatCard
|
| 186 |
+
icon="bolt"
|
| 187 |
+
label="Avg Latency (P95)"
|
| 188 |
+
value="145ms"
|
| 189 |
+
sub="Well within SLA"
|
| 190 |
+
sparkColor="#4edea3"
|
| 191 |
+
positive
|
| 192 |
+
/>
|
| 193 |
+
<StatCard
|
| 194 |
+
icon="warning"
|
| 195 |
+
label="Error Rate"
|
| 196 |
+
value="0.2%"
|
| 197 |
+
sub="Last 24 hours"
|
| 198 |
+
sparkColor="#ffb4ab"
|
| 199 |
+
positive={false}
|
| 200 |
+
/>
|
| 201 |
</div>
|
| 202 |
+
</div>
|
| 203 |
+
|
| 204 |
+
{/* ── Recent Activity ── */}
|
| 205 |
+
<div className="card">
|
| 206 |
+
<h2 className="text-title-lg text-on-surface mb-lg">Recent Endpoint Activity</h2>
|
| 207 |
+
<div className="space-y-2">
|
| 208 |
+
{[
|
| 209 |
+
{ method: 'POST', path: '/predict', status: 200, time: '3.5ms', ago: '2s ago' },
|
| 210 |
+
{ method: 'GET', path: '/health', status: 200, time: '0.8ms', ago: '5s ago' },
|
| 211 |
+
{ method: 'POST', path: '/batch', status: 202, time: '12.1ms', ago: '1m ago' },
|
| 212 |
+
{ method: 'GET', path: '/status/abc12', status: 200, time: '1.2ms', ago: '1m ago' },
|
| 213 |
+
{ method: 'POST', path: '/predict', status: 500, time: '23ms', ago: '3m ago' },
|
| 214 |
+
].map((req, i) => (
|
| 215 |
+
<div key={i} className="flex items-center gap-4 py-2 px-md rounded-lg hover:bg-white/[0.03] transition-colors">
|
| 216 |
+
<span className={`font-mono text-label-sm w-10 flex-shrink-0 ${
|
| 217 |
+
req.method === 'POST' ? 'text-primary' : 'text-tertiary'
|
| 218 |
+
}`}>
|
| 219 |
+
{req.method}
|
| 220 |
+
</span>
|
| 221 |
+
<span className="font-mono text-body-sm text-on-surface flex-1 truncate">{req.path}</span>
|
| 222 |
+
<span className={`font-mono text-label-sm w-10 text-right flex-shrink-0 ${
|
| 223 |
+
req.status >= 500 ? 'text-error' : req.status >= 400 ? 'text-warning' : 'text-tertiary'
|
| 224 |
+
}`}>
|
| 225 |
+
{req.status}
|
| 226 |
+
</span>
|
| 227 |
+
<span className="font-mono text-label-sm text-on-surface-variant w-14 text-right flex-shrink-0">{req.time}</span>
|
| 228 |
+
<span className="font-mono text-label-sm text-on-surface-variant/50 w-16 text-right hidden sm:block flex-shrink-0">{req.ago}</span>
|
| 229 |
+
</div>
|
| 230 |
+
))}
|
| 231 |
</div>
|
| 232 |
</div>
|
| 233 |
</div>
|
dashboard/src/pages/Predict.jsx
CHANGED
|
@@ -3,16 +3,15 @@ import LivePredictor from '../components/LivePredictor'
|
|
| 3 |
|
| 4 |
export default function Predict() {
|
| 5 |
return (
|
| 6 |
-
<div className="space-y-
|
| 7 |
<div>
|
| 8 |
-
<h1 className="text-
|
| 9 |
-
<p className="mt-1 text-
|
| 10 |
-
Enter
|
|
|
|
| 11 |
</p>
|
| 12 |
</div>
|
| 13 |
-
<
|
| 14 |
-
<LivePredictor />
|
| 15 |
-
</div>
|
| 16 |
</div>
|
| 17 |
)
|
| 18 |
}
|
|
|
|
| 3 |
|
| 4 |
export default function Predict() {
|
| 5 |
return (
|
| 6 |
+
<div className="space-y-xl">
|
| 7 |
<div>
|
| 8 |
+
<h1 className="text-headline-md text-on-surface">Live Sentiment Predictor</h1>
|
| 9 |
+
<p className="mt-1 text-body-md text-on-surface-variant max-w-2xl">
|
| 10 |
+
Enter text to analyze its aspects and sentiments in real-time.
|
| 11 |
+
The model automatically identifies the language and extracts key phrases.
|
| 12 |
</p>
|
| 13 |
</div>
|
| 14 |
+
<LivePredictor />
|
|
|
|
|
|
|
| 15 |
</div>
|
| 16 |
)
|
| 17 |
}
|
dashboard/tailwind.config.js
CHANGED
|
@@ -6,7 +6,124 @@ export default {
|
|
| 6 |
],
|
| 7 |
darkMode: 'class',
|
| 8 |
theme: {
|
| 9 |
-
extend: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
},
|
| 11 |
plugins: [],
|
| 12 |
}
|
|
|
|
| 6 |
],
|
| 7 |
darkMode: 'class',
|
| 8 |
theme: {
|
| 9 |
+
extend: {
|
| 10 |
+
colors: {
|
| 11 |
+
// Surface system
|
| 12 |
+
"surface": "#0b1326",
|
| 13 |
+
"surface-dim": "#0b1326",
|
| 14 |
+
"surface-bright": "#31394d",
|
| 15 |
+
"surface-container-lowest": "#060e20",
|
| 16 |
+
"surface-container-low": "#131b2e",
|
| 17 |
+
"surface-container": "#171f33",
|
| 18 |
+
"surface-container-high": "#222a3d",
|
| 19 |
+
"surface-container-highest":"#2d3449",
|
| 20 |
+
"surface-variant": "#2d3449",
|
| 21 |
+
"surface-tint": "#c0c1ff",
|
| 22 |
+
"background": "#0b1326",
|
| 23 |
+
// On-surface
|
| 24 |
+
"on-surface": "#dae2fd",
|
| 25 |
+
"on-surface-variant": "#c7c4d7",
|
| 26 |
+
"on-background": "#dae2fd",
|
| 27 |
+
"inverse-surface": "#dae2fd",
|
| 28 |
+
"inverse-on-surface": "#283044",
|
| 29 |
+
// Primary
|
| 30 |
+
"primary": "#c0c1ff",
|
| 31 |
+
"primary-fixed": "#e1e0ff",
|
| 32 |
+
"primary-fixed-dim": "#c0c1ff",
|
| 33 |
+
"primary-container": "#8083ff",
|
| 34 |
+
"on-primary": "#1000a9",
|
| 35 |
+
"on-primary-container": "#0d0096",
|
| 36 |
+
"on-primary-fixed": "#07006c",
|
| 37 |
+
"on-primary-fixed-variant": "#2f2ebe",
|
| 38 |
+
"inverse-primary": "#494bd6",
|
| 39 |
+
// Secondary
|
| 40 |
+
"secondary": "#c4c7c9",
|
| 41 |
+
"secondary-fixed": "#e0e3e5",
|
| 42 |
+
"secondary-fixed-dim": "#c4c7c9",
|
| 43 |
+
"secondary-container": "#464a4b",
|
| 44 |
+
"on-secondary": "#2d3133",
|
| 45 |
+
"on-secondary-container": "#b6b9bb",
|
| 46 |
+
"on-secondary-fixed": "#191c1e",
|
| 47 |
+
"on-secondary-fixed-variant":"#444749",
|
| 48 |
+
// Tertiary (green – positive sentiment)
|
| 49 |
+
"tertiary": "#4edea3",
|
| 50 |
+
"tertiary-fixed": "#6ffbbe",
|
| 51 |
+
"tertiary-fixed-dim": "#4edea3",
|
| 52 |
+
"tertiary-container": "#00885d",
|
| 53 |
+
"on-tertiary": "#003824",
|
| 54 |
+
"on-tertiary-container": "#000703",
|
| 55 |
+
"on-tertiary-fixed": "#002113",
|
| 56 |
+
"on-tertiary-fixed-variant":"#005236",
|
| 57 |
+
// Error (red – negative sentiment)
|
| 58 |
+
"error": "#ffb4ab",
|
| 59 |
+
"error-container": "#93000a",
|
| 60 |
+
"on-error": "#690005",
|
| 61 |
+
"on-error-container": "#ffdad6",
|
| 62 |
+
// Outline
|
| 63 |
+
"outline": "#908fa0",
|
| 64 |
+
"outline-variant": "#464554",
|
| 65 |
+
// Sentiment aliases
|
| 66 |
+
"positive": "#4edea3",
|
| 67 |
+
"negative": "#ffb4ab",
|
| 68 |
+
"warning": "#f5c542",
|
| 69 |
+
},
|
| 70 |
+
fontFamily: {
|
| 71 |
+
sans: ["Geist", "ui-sans-serif", "system-ui", "sans-serif"],
|
| 72 |
+
mono: ["JetBrains Mono", "ui-monospace", "monospace"],
|
| 73 |
+
geist: ["Geist", "sans-serif"],
|
| 74 |
+
},
|
| 75 |
+
fontSize: {
|
| 76 |
+
"display": ["36px", { lineHeight: "44px", letterSpacing: "-0.025em", fontWeight: "600" }],
|
| 77 |
+
"headline-lg":["32px", { lineHeight: "40px", letterSpacing: "-0.02em", fontWeight: "600" }],
|
| 78 |
+
"headline-md":["24px", { lineHeight: "32px", letterSpacing: "-0.01em", fontWeight: "600" }],
|
| 79 |
+
"headline-sm":["20px", { lineHeight: "28px", fontWeight: "500" }],
|
| 80 |
+
"title-lg": ["16px", { lineHeight: "24px", fontWeight: "600" }],
|
| 81 |
+
"title-md": ["14px", { lineHeight: "20px", fontWeight: "600" }],
|
| 82 |
+
"body-lg": ["16px", { lineHeight: "24px", fontWeight: "400" }],
|
| 83 |
+
"body-md": ["14px", { lineHeight: "20px", fontWeight: "400" }],
|
| 84 |
+
"body-sm": ["12px", { lineHeight: "16px", fontWeight: "400" }],
|
| 85 |
+
"label-lg": ["14px", { lineHeight: "20px", letterSpacing: "0.02em", fontWeight: "500" }],
|
| 86 |
+
"label-md": ["12px", { lineHeight: "16px", letterSpacing: "0.05em", fontWeight: "500" }],
|
| 87 |
+
"label-sm": ["11px", { lineHeight: "16px", letterSpacing: "0.06em", fontWeight: "500" }],
|
| 88 |
+
},
|
| 89 |
+
spacing: {
|
| 90 |
+
"xs": "4px",
|
| 91 |
+
"sm": "8px",
|
| 92 |
+
"md": "12px",
|
| 93 |
+
"lg": "16px",
|
| 94 |
+
"xl": "24px",
|
| 95 |
+
"2xl": "32px",
|
| 96 |
+
"3xl": "48px",
|
| 97 |
+
},
|
| 98 |
+
borderRadius: {
|
| 99 |
+
"sm": "4px",
|
| 100 |
+
"DEFAULT": "6px",
|
| 101 |
+
"md": "8px",
|
| 102 |
+
"lg": "12px",
|
| 103 |
+
"xl": "16px",
|
| 104 |
+
"2xl": "20px",
|
| 105 |
+
"full": "9999px",
|
| 106 |
+
},
|
| 107 |
+
boxShadow: {
|
| 108 |
+
"card": "0 1px 3px rgba(0,0,0,0.4), 0 1px 2px rgba(0,0,0,0.3)",
|
| 109 |
+
"elevated": "0 4px 16px rgba(0,0,0,0.5)",
|
| 110 |
+
"glow-primary": "0 0 20px rgba(192,193,255,0.15)",
|
| 111 |
+
"glow-positive": "0 0 12px rgba(78,222,163,0.2)",
|
| 112 |
+
"glow-negative": "0 0 12px rgba(255,180,171,0.2)",
|
| 113 |
+
},
|
| 114 |
+
backdropBlur: {
|
| 115 |
+
"glass": "12px",
|
| 116 |
+
},
|
| 117 |
+
animation: {
|
| 118 |
+
"pulse-slow": "pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite",
|
| 119 |
+
"fade-in": "fadeIn 0.2s ease-out",
|
| 120 |
+
"slide-in": "slideIn 0.25s ease-out",
|
| 121 |
+
},
|
| 122 |
+
keyframes: {
|
| 123 |
+
fadeIn: { from: { opacity: "0" }, to: { opacity: "1" } },
|
| 124 |
+
slideIn: { from: { opacity: "0", transform: "translateY(8px)" }, to: { opacity: "1", transform: "translateY(0)" } },
|
| 125 |
+
},
|
| 126 |
+
},
|
| 127 |
},
|
| 128 |
plugins: [],
|
| 129 |
}
|
requirements.txt
CHANGED
|
@@ -24,3 +24,4 @@ python-dotenv==1.0.1
|
|
| 24 |
pytest==8.2.0
|
| 25 |
httpx==0.27.0
|
| 26 |
prometheus-fastapi-instrumentator==7.0.0
|
|
|
|
|
|
| 24 |
pytest==8.2.0
|
| 25 |
httpx==0.27.0
|
| 26 |
prometheus-fastapi-instrumentator==7.0.0
|
| 27 |
+
python-multipart==0.0.9
|