Aryan Mishra commited on
Commit
927450f
·
1 Parent(s): ae0eb95

feat: updates to frontend dashboard and api services

Browse files
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
- engine = create_engine(DATABASE_URL)
 
 
 
 
 
 
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
- from pathlib import Path
3
  import time
 
 
 
4
  import numpy as np
5
- from typing import List, Dict, Any
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 ORTModelForTokenClassification, ORTModelForSequenceClassification
 
 
 
11
  from transformers import AutoTokenizer
12
- from huggingface_hub import hf_hub_download, snapshot_download
13
  OPTIMUM_AVAILABLE = True
14
  except ImportError:
15
- OPTIMUM_AVAILABLE = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # BIO tags for aspect extraction (example mapping)
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
- """Load ONNX models from local path or HuggingFace Hub.
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", "YOUR_HF_USERNAME/multilingual-absa")
42
- use_hub = os.getenv("MODEL_SOURCE", "local") == "huggingface_hub"
43
-
44
- aspect_path = model_path_base / "aspect_extraction_int8"
45
- sentiment_path = model_path_base / "sentiment_int8"
46
-
47
- if not aspect_path.exists() and not use_hub:
48
- aspect_path = model_path_base / "aspect_extraction"
49
- if not sentiment_path.exists() and not use_hub:
50
- sentiment_path = model_path_base / "sentiment"
51
-
52
- try:
53
- self.tokenizer = AutoTokenizer.from_pretrained("xlm-roberta-base")
54
- if use_hub or not aspect_path.exists():
55
- print(f"Downloading/loading from HF Hub: {hf_repo_id}")
56
- self.aspect_model = ORTModelForTokenClassification.from_pretrained(hf_repo_id, subfolder="aspect_extraction_int8")
57
- self.sentiment_model = ORTModelForSequenceClassification.from_pretrained(hf_repo_id, subfolder="sentiment_int8")
58
- else:
59
- print(f"Loading ONNX models from {aspect_path} and {sentiment_path}")
60
- self.aspect_model = ORTModelForTokenClassification.from_pretrained(str(aspect_path))
61
- self.sentiment_model = ORTModelForSequenceClassification.from_pretrained(str(sentiment_path))
62
- self.is_loaded = True
63
- except Exception as e:
64
- print(f"Failed to load ONNX models: {e}")
65
- self.is_loaded = False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
66
 
67
  def predict(self, text: str, requested_lang: str = None) -> PredictionResponse:
68
- """Run full ABSA pipeline on a single review.
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 if requested_lang else detected_lang
85
-
86
- if not self.is_loaded or not self.aspect_model:
87
- # Dummy response for testing without models
88
- process_time = (time.time() - start_time) * 1000
89
- return PredictionResponse(
90
- text=text,
91
- language=actual_lang,
92
- detected_language=detected_lang,
93
- aspects=[],
94
- processing_time_ms=process_time
95
- )
96
-
97
- # 1. Aspect Extraction
 
 
 
 
 
 
 
98
  inputs = self.tokenizer(text, return_tensors="pt", truncation=True, max_length=128)
99
- aspect_outputs = self.aspect_model(**inputs)
100
- logits = aspect_outputs.logits[0].detach().numpy()
101
- predictions = np.argmax(logits, axis=1)
102
-
103
  tokens = self.tokenizer.convert_ids_to_tokens(inputs["input_ids"][0])
104
-
105
- aspects = []
106
- current_aspect = []
107
- start_idx = -1
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 current_aspect:
117
- aspects.append(("".join(current_aspect).replace(" ", " ").strip(), start_idx, idx-1))
118
- current_aspect = [token]
119
- start_idx = idx
120
- elif label == "I-ASP" and current_aspect:
121
- current_aspect.append(token)
122
  else:
123
- if current_aspect:
124
- aspects.append(("".join(current_aspect).replace(" ", " ").strip(), start_idx, idx-1))
125
- current_aspect = []
126
-
127
- if current_aspect:
128
- aspects.append(("".join(current_aspect).replace(" ", " ").strip(), start_idx, len(tokens)-1))
129
-
130
- # 2. Sentiment Classification per aspect
131
  results = []
132
- for aspect_text, s_idx, e_idx in aspects:
133
- # For joint model, typically it's text + aspect
134
- # Here we just predict sentiment for the aspect within the context
135
- seq_input = self.tokenizer(text, text_pair=aspect_text, return_tensors="pt", truncation=True, max_length=128)
136
- sent_out = self.sentiment_model(**seq_input)
137
- sent_logits = sent_out.logits[0].detach().numpy()
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=aspect_text,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  sentiment=sentiment,
150
  confidence=confidence,
151
- start=s_idx,
152
- end=e_idx
153
  ))
154
-
155
- process_time = (time.time() - start_time) * 1000
156
-
157
- return PredictionResponse(
158
- text=text,
159
- language=actual_lang,
160
- detected_language=detected_lang,
161
- aspects=results,
162
- processing_time_ms=process_time
163
- )
164
-
165
- def predict_batch(self, texts: List[str]) -> List[PredictionResponse]:
166
- """Run full ABSA pipeline on a batch of reviews.
167
-
168
- Args:
169
- texts: List of raw review strings.
170
-
171
- Returns:
172
- List of PredictionResponse objects.
173
- """
174
- # simplified batch processing
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
- <!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 Dashboard</title>
 
 
 
 
 
 
 
 
 
8
  </head>
9
- <body class="bg-gray-50 dark:bg-slate-900 text-gray-900 dark:text-gray-100 transition-colors duration-200">
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="analytics" element={<Analytics />} />
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 Navbar from './Navbar'
 
 
 
 
 
 
 
4
 
5
  export default function Layout() {
6
- const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
 
 
7
 
8
  return (
9
- <div className="min-h-screen bg-gray-50 dark:bg-slate-900 transition-colors duration-200 flex flex-col">
10
- <Navbar
11
- isMobileMenuOpen={isMobileMenuOpen}
12
- toggleMobileMenu={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
 
 
 
 
 
 
 
 
 
 
13
  />
14
- <main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-8">
15
- <Outlet />
16
- </main>
17
- <footer className="bg-white dark:bg-slate-800 border-t border-gray-200 dark:border-slate-700 py-6">
18
- <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-sm text-gray-500 dark:text-gray-400">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  SentimentAI — Phase 6 Analytics Dashboard
20
- </div>
21
- </footer>
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 { Loader2 } from 'lucide-react'
5
-
6
- const getSentimentColor = (sentiment) => {
7
- switch(sentiment.toLowerCase()) {
8
- case 'positive': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-green-200 dark:border-green-800'
9
- case 'negative': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 border-red-200 dark:border-red-800'
10
- case 'neutral': return 'bg-gray-100 text-gray-800 dark:bg-slate-700 dark:text-gray-200 border-gray-200 dark:border-slate-600'
11
- case 'conflict': return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 border-orange-200 dark:border-orange-800'
12
- default: return 'bg-gray-100 text-gray-800 dark:bg-slate-700 dark:text-gray-200'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 renderHighlightedText = (originalText, aspects) => {
30
- if (!aspects || aspects.length === 0) return <p className="text-gray-700 dark:text-gray-300">{originalText}</p>
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-2 gap-8">
65
- {/* Left Panel: Input */}
66
- <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-6 flex flex-col h-full">
67
- <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Analyze Review</h2>
68
-
69
- <div className="mb-4">
70
- <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
71
- Language
72
- </label>
73
- <select
74
- value={language}
75
- onChange={(e) => setLanguage(e.target.value)}
76
- className="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"
77
- >
78
- <option value="">Auto-detect</option>
79
- <option value="en">English</option>
80
- <option value="hi">Hindi</option>
81
- <option value="hinglish">Hinglish</option>
82
- </select>
83
- </div>
84
-
85
- <div className="flex-1 flex flex-col mb-4">
86
- <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
87
- Review Text
88
- </label>
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
- {/* Right Panel: Results */}
120
- <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-6 flex flex-col h-full">
121
- <h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">Results</h2>
122
-
123
- {!mutation.data && !mutation.isPending && (
124
- <div className="flex-1 flex items-center justify-center text-gray-500 dark:text-gray-400">
125
- Enter a review and click analyze to see results.
126
- </div>
127
- )}
128
-
129
- {mutation.isPending && (
130
- <div className="flex-1 flex flex-col items-center justify-center text-gray-500 dark:text-gray-400 gap-4">
131
- <Loader2 className="animate-spin h-8 w-8 text-indigo-500" />
132
- <p>Processing text via ONNX models...</p>
133
- </div>
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
- <div className="text-xs text-gray-500 dark:text-gray-400">
146
- {mutation.data.processing_time_ms?.toFixed(1)} ms
 
 
 
 
 
 
 
 
 
 
147
  </div>
148
- </div>
149
-
150
- <div className="mb-6 bg-gray-50 dark:bg-slate-900/50 p-4 rounded-lg">
151
- {renderHighlightedText(mutation.data.text, mutation.data.aspects)}
152
- </div>
153
-
154
- <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Extracted Aspects</h3>
155
-
156
- <div className="flex-1 overflow-y-auto pr-2 space-y-3">
157
- {mutation.data.aspects && mutation.data.aspects.length > 0 ? (
158
- mutation.data.aspects.map((asp, idx) => (
159
- <div key={idx} className="bg-white dark:bg-slate-700 border border-gray-200 dark:border-slate-600 rounded-lg p-3 shadow-sm">
160
- <div className="flex justify-between items-start mb-2">
161
- <span className="font-medium text-gray-900 dark:text-white">{asp.aspect}</span>
162
- <span className={`px-2 py-0.5 rounded text-xs font-medium uppercase border ${getSentimentColor(asp.sentiment)}`}>
163
- {asp.sentiment}
164
- </span>
165
- </div>
166
- <div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
167
- <span>Confidence</span>
168
- <div className="flex-1 h-1.5 bg-gray-200 dark:bg-slate-600 rounded-full overflow-hidden">
169
- <div
170
- className="h-full bg-indigo-500 rounded-full"
171
- style={{ width: `${Math.round(asp.confidence * 100)}%` }}
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
- </div>
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
- @layer base {
6
- html, body {
7
- @apply h-full antialiased;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  }
9
- #root {
10
- @apply h-full;
 
 
 
 
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 { api } from '../api/client'
5
- import { UploadCloud, File, AlertCircle, Loader2, Download } from 'lucide-react'
6
  import toast from 'react-hot-toast'
7
- import SentimentChart from '../components/SentimentChart'
 
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 Mutation
17
  const uploadMutation = useMutation({
18
  mutationFn: (f) => api.uploadBatch(f),
19
  onSuccess: (data) => {
20
  setJobId(data.job_id)
21
  setIsPolling(true)
22
- toast.success("Batch job queued successfully")
23
- }
24
  })
25
 
26
- // Poll Job Status
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
- toast.success("Batch processing completed!")
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 selectedFile = acceptedFiles[0]
48
- if (!selectedFile.name.endsWith('.csv')) {
49
- toast.error("Please upload a CSV file")
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
- if (!file) return
64
- uploadMutation.mutate(file)
65
- }
66
-
67
- const resetUpload = () => {
68
- setFile(null)
69
- setJobId(null)
70
- setIsPolling(false)
71
- }
72
 
73
- const progress = jobStatus ? Math.min(100, Math.round((jobStatus.processed / jobStatus.total_reviews) * 100)) : 0
 
 
74
 
75
  return (
76
- <div className="space-y-6">
 
77
  <div>
78
- <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Batch Analytics</h1>
79
- <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
80
  Upload a CSV of reviews for bulk aspect-based sentiment analysis.
81
  </p>
82
  </div>
83
 
84
- {/* Upload Section */}
85
  {!jobId && (
86
- <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-8">
87
- <div
88
- {...getRootProps()}
89
- className={`border-2 border-dashed rounded-xl p-12 text-center cursor-pointer transition-colors ${
90
- isDragActive
91
- ? 'border-indigo-500 bg-indigo-50 dark:bg-indigo-900/20'
92
- : 'border-gray-300 dark:border-slate-600 hover:border-indigo-400 hover:bg-gray-50 dark:hover:bg-slate-700/50'
93
- }`}
 
94
  >
95
  <input {...getInputProps()} />
96
- <UploadCloud className="mx-auto h-12 w-12 text-gray-400 dark:text-gray-500 mb-4" />
97
- <h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
98
- {isDragActive ? "Drop the CSV file here" : "Drag & drop a CSV file, or click to select"}
 
 
 
 
99
  </h3>
100
- <p className="text-sm text-gray-500 dark:text-gray-400">
101
- Must contain a 'text' column. Maximum 10,000 rows.
 
102
  </p>
103
  </div>
104
 
 
105
  {file && (
106
- <div className="mt-6 flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg border border-gray-200 dark:border-slate-600">
 
 
107
  <div className="flex items-center gap-3">
108
- <File className="h-6 w-6 text-indigo-500" />
109
  <div>
110
- <p className="text-sm font-medium text-gray-900 dark:text-white">{file.name}</p>
111
- <p className="text-xs text-gray-500 dark:text-gray-400">{(file.size / 1024 / 1024).toFixed(2)} MB</p>
 
 
112
  </div>
113
  </div>
114
- <div className="flex gap-3">
115
- <button
116
- onClick={(e) => { e.stopPropagation(); setFile(null); }}
117
- className="px-3 py-1.5 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"
 
118
  >
119
  Remove
120
  </button>
121
- <button
122
- onClick={(e) => { e.stopPropagation(); handleUpload(); }}
123
  disabled={uploadMutation.isPending}
124
- className="px-4 py-1.5 flex items-center text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 transition-colors"
125
  >
126
- {uploadMutation.isPending ? <Loader2 className="animate-spin h-4 w-4 mr-2" /> : null}
 
 
 
 
127
  Process File
128
  </button>
129
  </div>
130
  </div>
131
  )}
132
- </div>
133
  )}
134
 
135
- {/* Progress Section */}
136
  {jobId && (
137
- <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-6">
138
- <div className="flex justify-between items-center mb-4">
139
  <div>
140
- <h3 className="text-lg font-medium text-gray-900 dark:text-white flex items-center gap-2">
141
- {jobStatus?.status === 'completed' && <span className="h-3 w-3 rounded-full bg-green-500"></span>}
142
- {jobStatus?.status === 'processing' && <span className="h-3 w-3 rounded-full bg-blue-500 animate-pulse"></span>}
143
- {jobStatus?.status === 'failed' && <span className="h-3 w-3 rounded-full bg-red-500"></span>}
144
- {jobStatus?.status === 'queued' && <span className="h-3 w-3 rounded-full bg-gray-400"></span>}
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 text-sm font-medium text-gray-700 dark:text-gray-300">
174
  <span>Progress</span>
175
- <span>{jobStatus ? `${jobStatus.processed} / ${jobStatus.total_reviews} (${progress}%)` : '0%'}</span>
 
 
 
176
  </div>
177
- <div className="w-full bg-gray-200 dark:bg-slate-700 rounded-full h-2.5 overflow-hidden">
178
- <div
179
- className={`h-full rounded-full transition-all duration-500 ease-out ${
180
- jobStatus?.status === 'failed' ? 'bg-red-500' : 'bg-indigo-600'
181
  }`}
182
  style={{ width: `${progress}%` }}
183
- ></div>
184
  </div>
185
  </div>
186
- </div>
187
  )}
188
 
189
- {/* Analytics Charts */}
190
- {jobStatus?.status === 'completed' && (
191
- <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
192
- <div className="xl:col-span-2">
193
- <AspectHeatmap />
194
- </div>
195
- <div>
196
- <LanguagePie />
197
- </div>
198
- <div className="lg:col-span-2 xl:col-span-3">
199
- <SentimentChart />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ &nbsp;({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 { Activity, Server, Clock, AlertTriangle, ShieldCheck, Database, Zap } from 'lucide-react'
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-6">
17
- <div className="flex justify-between items-end">
 
18
  <div>
19
- <h1 className="text-2xl font-bold text-gray-900 dark:text-white">System Monitor</h1>
20
- <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
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="text-sm text-gray-600 dark:text-gray-300">Auto-refresh:</label>
27
- <select
 
 
 
28
  value={refreshInterval}
29
  onChange={(e) => setRefreshInterval(Number(e.target.value))}
30
- className="rounded-md border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
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
- <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
41
- {/* Status Card */}
42
- <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-6">
43
- <div className="flex items-center gap-3 mb-4">
44
- <div className={`p-3 rounded-lg ${health?.status === 'ok' ? 'bg-green-100 dark:bg-green-900/50 text-green-600 dark:text-green-400' : 'bg-red-100 dark:bg-red-900/50 text-red-600 dark:text-red-400'}`}>
45
- <Activity className="h-6 w-6" />
 
 
 
 
 
46
  </div>
47
  <div>
48
- <h2 className="text-lg font-semibold text-gray-900 dark:text-white">API Status</h2>
49
- <p className="text-sm text-gray-500 dark:text-gray-400">Core Inference Engine</p>
50
  </div>
51
  </div>
52
- <div className="flex items-center gap-2 mt-6">
53
- <span className="text-sm font-medium text-gray-600 dark:text-gray-300">Current state:</span>
54
- <span className={`px-2.5 py-1 rounded-full text-xs font-semibold ${
55
- health?.status === 'ok'
56
- ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
57
- : 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 animate-pulse'
58
- }`}>
59
- {isLoading ? 'Checking...' : (health?.status === 'ok' ? 'HEALTHY' : 'UNHEALTHY')}
60
- </span>
61
  </div>
62
  </div>
63
 
64
- {/* Model Info */}
65
- <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-6 md:col-span-2">
66
- <div className="flex items-center gap-3 mb-6">
67
- <div className="p-3 rounded-lg bg-indigo-100 dark:bg-indigo-900/50 text-indigo-600 dark:text-indigo-400">
68
- <Server className="h-6 w-6" />
69
  </div>
70
  <div>
71
- <h2 className="text-lg font-semibold text-gray-900 dark:text-white">Model Configuration</h2>
72
- <p className="text-sm text-gray-500 dark:text-gray-400">Loaded ONNX Graphs</p>
73
  </div>
74
  </div>
75
-
76
- <div className="grid grid-cols-2 gap-4">
77
- <div className="bg-gray-50 dark:bg-slate-700/50 p-4 rounded-lg">
78
- <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Architecture</p>
79
- <p className="font-medium text-gray-900 dark:text-white">XLM-RoBERTa (INT8)</p>
80
- </div>
81
- <div className="bg-gray-50 dark:bg-slate-700/50 p-4 rounded-lg">
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-gray-50 dark:bg-slate-700/50 p-4 rounded-lg">
93
- <p className="text-xs text-gray-500 dark:text-gray-400 mb-1">Sentiment Classification</p>
94
- <div className="flex items-center gap-1 font-medium text-gray-900 dark:text-white">
95
- <ShieldCheck className="h-4 w-4 text-green-500" />
96
- Loaded
97
- </div>
98
  </div>
99
  </div>
100
  </div>
101
  </div>
102
 
103
- {/* Metrics Row */}
104
- <h2 className="text-lg font-semibold text-gray-900 dark:text-white pt-4">Performance Metrics</h2>
105
- <div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
106
- <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-6 flex flex-col justify-center items-center text-center">
107
- <Database className="h-8 w-8 text-blue-500 mb-3" />
108
- <h3 className="text-3xl font-bold text-gray-900 dark:text-white">12.4k</h3>
109
- <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Total Requests Today</p>
110
- </div>
111
-
112
- <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-6 flex flex-col justify-center items-center text-center">
113
- <Zap className="h-8 w-8 text-yellow-500 mb-3" />
114
- <h3 className="text-3xl font-bold text-gray-900 dark:text-white">145ms</h3>
115
- <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Average Latency (P95)</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  </div>
117
-
118
- <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-6 flex flex-col justify-center items-center text-center">
119
- <AlertTriangle className="h-8 w-8 text-red-500 mb-3" />
120
- <h3 className="text-3xl font-bold text-gray-900 dark:text-white">0.2%</h3>
121
- <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">Error Rate</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-6 h-full flex flex-col">
7
  <div>
8
- <h1 className="text-2xl font-bold text-gray-900 dark:text-white">Live Sentiment Predictor</h1>
9
- <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
10
- Enter a product review to analyze its aspects and sentiments in real-time.
 
11
  </p>
12
  </div>
13
- <div className="flex-1">
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