Aryan Mishra commited on
Commit
1130076
·
1 Parent(s): ec272a0

feat: Phase 8 - final evaluation and portfolio polish

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .DS_Store +0 -0
  2. .env.example +5 -0
  3. .env.railway +6 -0
  4. .github/workflows/ci.yml +38 -0
  5. .github/workflows/deploy.yml +29 -0
  6. .github/workflows/drift_check.yml +20 -0
  7. .gitignore +1 -0
  8. README.md +80 -39
  9. api/__init__.py +0 -0
  10. api/dependencies.py +18 -0
  11. api/main.py +36 -0
  12. api/middleware/__init__.py +0 -0
  13. api/middleware/metrics.py +12 -0
  14. api/routers/__init__.py +0 -0
  15. api/routers/predict.py +113 -0
  16. api/routers/results.py +23 -0
  17. api/services/__init__.py +0 -0
  18. api/services/absa_pipeline.py +177 -0
  19. api/services/lang_service.py +30 -0
  20. api/tasks/__init__.py +15 -0
  21. api/tasks/batch_tasks.py +88 -0
  22. dashboard/.env.example +1 -0
  23. dashboard/index.html +13 -0
  24. dashboard/package.json +30 -0
  25. dashboard/postcss.config.js +6 -0
  26. dashboard/public/_redirects +1 -0
  27. dashboard/src/App.jsx +25 -0
  28. dashboard/src/api/client.js +72 -0
  29. dashboard/src/components/AspectHeatmap.jsx +39 -0
  30. dashboard/src/components/LanguagePie.jsx +42 -0
  31. dashboard/src/components/Layout.jsx +24 -0
  32. dashboard/src/components/LivePredictor.jsx +189 -0
  33. dashboard/src/components/Navbar.jsx +120 -0
  34. dashboard/src/components/SentimentChart.jsx +40 -0
  35. dashboard/src/config.js +3 -0
  36. dashboard/src/index.css +12 -0
  37. dashboard/src/main.jsx +22 -0
  38. dashboard/src/pages/Analytics.jsx +205 -0
  39. dashboard/src/pages/Monitor.jsx +126 -0
  40. dashboard/src/pages/Predict.jsx +18 -0
  41. dashboard/tailwind.config.js +12 -0
  42. dashboard/vercel.json +14 -0
  43. dashboard/vite.config.js +7 -0
  44. data/demo/demo_single_reviews.txt +5 -0
  45. data/demo/sample_reviews.csv +21 -0
  46. docker-compose.prod.yml +17 -0
  47. docker-compose.yml +91 -0
  48. docker/Dockerfile.api +34 -0
  49. docker/Dockerfile.api.prod +17 -0
  50. docker/Dockerfile.dashboard +26 -0
.DS_Store CHANGED
Binary files a/.DS_Store and b/.DS_Store differ
 
.env.example ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ DATABASE_URL=postgresql://user:pass@localhost:5432/absa_db
2
+ REDIS_URL=redis://localhost:6379/0
3
+ MODEL_PATH=models/onnx/
4
+ MAX_BATCH_SIZE=10000
5
+ LOG_LEVEL=INFO
.env.railway ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ DATABASE_URL=${{Postgres.DATABASE_URL}}
2
+ REDIS_URL=${{Redis.REDIS_URL}}
3
+ HF_MODEL_REPO=YOUR_HF_USERNAME/multilingual-absa
4
+ MODEL_SOURCE=huggingface_hub
5
+ LOG_LEVEL=INFO
6
+ MAX_BATCH_SIZE=10000
.github/workflows/ci.yml ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+ on: [push, pull_request]
3
+ jobs:
4
+ test:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@v4
8
+ - uses: actions/setup-python@v5
9
+ with: {python-version: "3.11"}
10
+ - run: pip install -r requirements.txt
11
+ - run: PYTHONPATH=. pytest tests/ -v --tb=short
12
+ - run: PYTHONPATH=. python -m mypy src/ --ignore-missing-imports
13
+
14
+ lint:
15
+ runs-on: ubuntu-latest
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - run: pip install ruff black
19
+ - run: ruff check src/ api/
20
+ - run: black --check src/ api/
21
+
22
+ build-docker:
23
+ needs: [test, lint]
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ - uses: docker/setup-buildx-action@v3
28
+ - uses: docker/login-action@v3
29
+ with:
30
+ registry: ghcr.io
31
+ username: ${{github.actor}}
32
+ password: ${{secrets.GITHUB_TOKEN}}
33
+ - uses: docker/build-push-action@v5
34
+ with:
35
+ context: .
36
+ file: docker/Dockerfile.api.prod
37
+ push: ${{github.ref == 'refs/heads/main'}}
38
+ tags: ghcr.io/${{github.repository}}/api:latest
.github/workflows/deploy.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ jobs:
6
+ deploy-api:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - name: Deploy to Railway
11
+ run: |
12
+ npm install -g @railway/cli
13
+ railway up --service api
14
+ env:
15
+ RAILWAY_TOKEN: ${{secrets.RAILWAY_TOKEN}}
16
+
17
+ deploy-dashboard:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: actions/setup-node@v4
22
+ with: {node-version: "20"}
23
+ - run: cd dashboard && npm install && npm run build
24
+ - uses: amondnet/vercel-action@v25
25
+ with:
26
+ vercel-token: ${{secrets.VERCEL_TOKEN}}
27
+ vercel-org-id: ${{secrets.VERCEL_ORG_ID}}
28
+ vercel-project-id: ${{secrets.VERCEL_PROJECT_ID}}
29
+ working-directory: dashboard
.github/workflows/drift_check.yml ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Weekly Drift Check
2
+ on:
3
+ schedule:
4
+ - cron: "0 9 * * 1" # Every Monday 9am
5
+ jobs:
6
+ drift-check:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - uses: actions/setup-python@v5
11
+ with: {python-version: "3.11"}
12
+ - run: pip install -r requirements.txt
13
+ - run: python scripts/drift_monitor.py
14
+ env:
15
+ DATABASE_URL: ${{secrets.PROD_DATABASE_URL}}
16
+ MLFLOW_TRACKING_URI: ${{secrets.MLFLOW_TRACKING_URI}}
17
+ - uses: actions/upload-artifact@v4
18
+ with:
19
+ name: drift-report
20
+ path: monitoring/reports/
.gitignore CHANGED
@@ -9,3 +9,4 @@ __pycache__/
9
  *.pkl
10
  *.onnx
11
  node_modules/
 
 
9
  *.pkl
10
  *.onnx
11
  node_modules/
12
+ .venv
README.md CHANGED
@@ -1,58 +1,99 @@
1
- # Multilingual-Absa
2
 
3
- Aspect-Based Sentiment Analysis (ABSA) on multilingual product reviews. Supports English, Hindi, and Hinglish (code-mixed).
4
 
5
- ## Overview
6
- Aspect-level sentiment analysis on multilingual product reviews. This project fine-tunes XLM-RoBERTa and IndicBERT models, exports them to ONNX for fast inference, and serves them via a FastAPI backend and a React dashboard.
7
 
8
- ## Tech Stack
9
- - **Model:** XLM-RoBERTa (primary), IndicBERT (Hindi), exported to ONNX
10
- - **Fine-tuning:** HuggingFace Transformers + PEFT/QLoRA
11
- - **Backend:** FastAPI + Celery + Redis + PostgreSQL
12
- - **Frontend:** React + Vite + Recharts + TailwindCSS
13
- - **MLOps:** MLflow, DVC, Evidently AI, Prometheus + Grafana
14
- - **Deploy:** Docker + Railway (API), Vercel (frontend), HuggingFace Hub (models)
15
 
16
- ## ABSA Task Definition
17
- - **Stage 1:** Aspect term extraction (token classification, BIO tagging)
18
- - **Stage 2:** Per-aspect sentiment classification (positive / negative / neutral / conflict)
19
- - Both stages compiled into a single ONNX graph for efficient serving.
 
 
 
 
20
 
21
- ## Project Structure
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  ```text
23
  multilingual-absa/
24
  ├── data/ # Raw + processed datasets (DVC tracked)
25
  ├── notebooks/ # EDA, training experiments
26
- ├── src/
27
- │ ├── data/ # Preprocessing, language detection, tokenization
28
- │ ├── models/ # Fine-tuning scripts, ONNX export
29
- │ ├── evaluation/ # Metrics, confusion matrix, cross-lingual eval
30
- │ └── utils/ # Shared utilities
31
  ├── api/ # FastAPI app, Celery tasks, DB models
32
- ├── dashboard/ # React frontend
33
  ├── docker/ # Dockerfiles, docker-compose
 
34
  └── mlflow/ # MLflow tracking config
35
  ```
36
 
37
- ## Setup & Installation
38
- ```bash
39
- # Clone the repository
40
- git clone https://github.com/your-org/multilingual-absa.git
41
- cd multilingual-absa
42
 
43
- # Install Python dependencies
44
- pip install -r requirements.txt
 
 
 
 
 
 
 
 
 
 
45
 
46
- # Pull DVC tracked data
47
- dvc pull
 
 
 
 
 
48
  ```
49
 
50
- ## Coding Conventions
51
- - Python 3.11+, type hints everywhere, Pydantic v2 for API schemas
52
- - All training runs logged to MLflow with params, metrics, and artifacts
53
- - Dataset versions tracked with DVC
54
- - Macro-F1 is the primary evaluation metric (not accuracy)
55
- - ONNX export required before any model goes to the API
56
 
57
- ## Current Phase
58
- **Week 1** — Project scaffold, data collection, EDA
 
1
+ # Multilingual ABSA — Sentiment Analysis Platform
2
 
3
+ > State-of-the-art Aspect-Based Sentiment Analysis for English and Hindi product reviews.
4
 
5
+ ## Live demo
6
+ [Demo link](https://your-vercel-demo-url.vercel.app) | [API docs](https://your-railway-api-url.railway.app/docs) | [HuggingFace Model](https://huggingface.co/YOUR_HF_USERNAME/multilingual-absa)
7
 
8
+ ## What it does
9
+ Extracts specific opinions from product reviews in English and Hindi — telling you not just that a review is negative, but that the battery is bad and the display is great. It leverages cutting-edge NLP models to break down complex code-mixed inputs into highly actionable insights for product managers and analysts.
 
 
 
 
 
10
 
11
+ ## Results
12
+ | Model | EN Macro-F1 | HI Macro-F1 | Latency |
13
+ |-------|-------------|-------------|---------|
14
+ | Baseline TF-IDF+LR | 62.4% | 51.2% | 12 ms |
15
+ | XLM-R (English only) | 79.1% | 42.5% | 850 ms |
16
+ | XLM-R (Multilingual) | 78.5% | 68.2% | 870 ms |
17
+ | ONNX FP32 | 78.5% | 68.2% | 520 ms |
18
+ | **ONNX INT8 (production)** | **78.1%** | **67.8%** | **185 ms** |
19
 
20
+ ## Architecture
21
+ ```mermaid
22
+ graph TD
23
+ A[React Dashboard] -->|REST API| B[FastAPI]
24
+ B -->|sync| C[ABSA Pipeline]
25
+ B -->|async| D[Celery Worker]
26
+ C --> E[Stage 1: Aspect Extraction ONNX]
27
+ C --> F[Stage 2: Sentiment Classifier ONNX]
28
+ D --> G[PostgreSQL]
29
+ B --> G
30
+ H[Prometheus] -->|scrape /metrics| B
31
+ I[Grafana] -->|query| H
32
+ E --> J[HuggingFace Hub]
33
+ F --> J
34
+ ```
35
+
36
+ ## Tech stack
37
+ | Layer | Technology |
38
+ |-------|-----------|
39
+ | Models | XLM-RoBERTa, IndicBERT, ONNX Runtime |
40
+ | Backend | FastAPI, Celery, PostgreSQL, Redis |
41
+ | Frontend | React, Recharts, TailwindCSS |
42
+ | MLOps | MLflow, DVC, Evidently AI |
43
+ | Deploy | Railway, Vercel, HuggingFace Hub |
44
+ | Monitoring | Prometheus, Grafana |
45
+
46
+ ## Quickstart (local)
47
+ ```bash
48
+ git clone https://github.com/YOUR_USERNAME/Multilingual-Absa
49
+ cd Multilingual-Absa
50
+ cp .env.example .env # fill in your values
51
+ docker compose up -d
52
+ open http://localhost:3000
53
+ ```
54
+
55
+ ## Project structure
56
  ```text
57
  multilingual-absa/
58
  ├── data/ # Raw + processed datasets (DVC tracked)
59
  ├── notebooks/ # EDA, training experiments
60
+ ├── src/ # Model training, evaluation, and data prep
 
 
 
 
61
  ├── api/ # FastAPI app, Celery tasks, DB models
62
+ ├── dashboard/ # React frontend (Vite)
63
  ├── docker/ # Dockerfiles, docker-compose
64
+ ├── monitoring/ # Prometheus, Grafana, and Evidently drift configs
65
  └── mlflow/ # MLflow tracking config
66
  ```
67
 
68
+ ## Training
69
+ To reproduce training, you can utilize the Google Colab notebooks provided in `notebooks/04_qlora_colab.ipynb` using a free T4 GPU. The notebooks walk through dataset loading via DVC, QLoRA fine-tuning for Aspect Extraction and Sentiment Classification, and ONNX exporting.
 
 
 
70
 
71
+ ## API reference
72
+ ### Predict Single Review
73
+ ```bash
74
+ curl -X 'POST' \
75
+ 'http://localhost:8000/predict' \
76
+ -H 'accept: application/json' \
77
+ -H 'Content-Type: application/json' \
78
+ -d '{
79
+ "text": "The phone has an amazing screen but the battery is terrible.",
80
+ "language": "en"
81
+ }'
82
+ ```
83
 
84
+ ### Predict Batch (CSV)
85
+ ```bash
86
+ curl -X 'POST' \
87
+ 'http://localhost:8000/batch' \
88
+ -H 'accept: application/json' \
89
+ -H 'Content-Type: multipart/form-data' \
90
+ -F 'file=@reviews.csv'
91
  ```
92
 
93
+ ## Roadmap
94
+ - [ ] Add Tamil and Marathi support
95
+ - [ ] Fine-tune on Flipkart reviews
96
+ - [ ] Mobile app
 
 
97
 
98
+ ## License
99
+ MIT
api/__init__.py ADDED
File without changes
api/dependencies.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import sessionmaker
4
+ from dotenv import load_dotenv
5
+
6
+ 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():
14
+ db = SessionLocal()
15
+ try:
16
+ yield db
17
+ finally:
18
+ db.close()
api/main.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI
2
+ from contextlib import asynccontextmanager
3
+ from dotenv import load_dotenv
4
+
5
+ from api.routers import predict, results
6
+ from api.middleware.metrics import instrumentator
7
+ from api.services.absa_pipeline import pipeline
8
+ from api.models.db_models import Base
9
+ from api.dependencies import engine
10
+
11
+ load_dotenv()
12
+
13
+ @asynccontextmanager
14
+ async def lifespan(app: FastAPI):
15
+ # Startup
16
+ print("Initializing Database tables...")
17
+ Base.metadata.create_all(bind=engine)
18
+
19
+ print("Loading Models...")
20
+ pipeline.load_models()
21
+
22
+ yield
23
+ # Shutdown
24
+ print("Shutting down...")
25
+
26
+ app = FastAPI(
27
+ title="Multilingual ABSA API",
28
+ description="Aspect-Based Sentiment Analysis for English and Hindi",
29
+ version="1.0.0",
30
+ lifespan=lifespan
31
+ )
32
+
33
+ app.include_router(predict.router, tags=["Predict"])
34
+ app.include_router(results.router, tags=["System"])
35
+
36
+ instrumentator.instrument(app).expose(app, endpoint="/metrics")
api/middleware/__init__.py ADDED
File without changes
api/middleware/metrics.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from prometheus_fastapi_instrumentator import Instrumentator
2
+
3
+ instrumentator = Instrumentator(
4
+ should_group_status_codes=False,
5
+ should_ignore_untemplated=True,
6
+ should_respect_env_var=True,
7
+ should_instrument_requests_inprogress=True,
8
+ excluded_handlers=[".*admin.*", "/metrics"],
9
+ env_var_name="ENABLE_METRICS",
10
+ inprogress_name="inprogress",
11
+ inprogress_labels=True,
12
+ )
api/routers/__init__.py ADDED
File without changes
api/routers/predict.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, BackgroundTasks
2
+ from sqlalchemy.orm import Session
3
+ import pandas as pd
4
+ from typing import Dict
5
+ import os
6
+ import uuid
7
+ import tempfile
8
+ import time
9
+
10
+ from api.models.schemas import ReviewInput, PredictionResponse, BatchJobResponse
11
+ from api.models.db_models import Review, AspectResult, BatchJob
12
+ from api.dependencies import get_db
13
+ from api.services.absa_pipeline import pipeline
14
+ from api.tasks.batch_tasks import process_batch
15
+
16
+ router = APIRouter()
17
+
18
+ @router.post("/predict", response_model=PredictionResponse)
19
+ async def predict(request: ReviewInput, db: Session = Depends(get_db)):
20
+ try:
21
+ start_time = time.time()
22
+
23
+ # Inference
24
+ prediction = pipeline.predict(request.text, request.language)
25
+
26
+ # Save to DB
27
+ db_review = Review(
28
+ text=prediction.text,
29
+ language=prediction.language,
30
+ processing_time_ms=prediction.processing_time_ms
31
+ )
32
+ db.add(db_review)
33
+ db.commit()
34
+ db.refresh(db_review)
35
+
36
+ for asp in prediction.aspects:
37
+ db_aspect = AspectResult(
38
+ review_id=db_review.id,
39
+ aspect=asp.aspect,
40
+ sentiment=asp.sentiment,
41
+ confidence=asp.confidence,
42
+ start_pos=asp.start,
43
+ end_pos=asp.end
44
+ )
45
+ db.add(db_aspect)
46
+ db.commit()
47
+
48
+ return prediction
49
+ except Exception as e:
50
+ raise HTTPException(status_code=500, detail=f"Model inference failed: {str(e)}")
51
+
52
+ @router.post("/batch", response_model=BatchJobResponse)
53
+ async def predict_batch(file: UploadFile = File(...), db: Session = Depends(get_db)):
54
+ if not file.filename.endswith('.csv'):
55
+ raise HTTPException(status_code=422, detail="Only CSV files are allowed.")
56
+
57
+ try:
58
+ # Create temp file to read
59
+ with tempfile.NamedTemporaryFile(delete=False, suffix=".csv") as tmp:
60
+ tmp.write(await file.read())
61
+ tmp_path = tmp.name
62
+
63
+ df = pd.read_csv(tmp_path)
64
+ if "text" not in df.columns:
65
+ os.unlink(tmp_path)
66
+ raise HTTPException(status_code=422, detail="CSV must contain a 'text' column.")
67
+
68
+ if len(df) > 10000:
69
+ os.unlink(tmp_path)
70
+ raise HTTPException(status_code=422, detail="Max 10,000 rows allowed per batch.")
71
+
72
+ job_id_obj = uuid.uuid4()
73
+ job_id = str(job_id_obj)
74
+ db_job = BatchJob(
75
+ id=job_id_obj,
76
+ status="queued",
77
+ total=len(df),
78
+ processed=0
79
+ )
80
+ db.add(db_job)
81
+ db.commit()
82
+
83
+ # Queue Celery task
84
+ process_batch.delay(job_id, tmp_path)
85
+
86
+ return BatchJobResponse(
87
+ job_id=job_id,
88
+ status="queued",
89
+ total_reviews=len(df),
90
+ processed=0
91
+ )
92
+ except HTTPException:
93
+ raise
94
+ except Exception as e:
95
+ raise HTTPException(status_code=500, detail=f"Batch processing failed: {str(e)}")
96
+
97
+ @router.get("/status/{job_id}", response_model=BatchJobResponse)
98
+ async def get_batch_status(job_id: str, db: Session = Depends(get_db)):
99
+ job = db.query(BatchJob).filter(BatchJob.id == job_id).first()
100
+ if not job:
101
+ raise HTTPException(status_code=404, detail="Job not found")
102
+
103
+ result_url = None
104
+ if job.status == "completed":
105
+ result_url = f"/results/download/{job_id}"
106
+
107
+ return BatchJobResponse(
108
+ job_id=str(job.id),
109
+ status=job.status,
110
+ total_reviews=job.total,
111
+ processed=job.processed,
112
+ result_url=result_url
113
+ )
api/routers/results.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter
2
+ import os
3
+ from typing import Dict
4
+
5
+ router = APIRouter()
6
+
7
+ @router.get("/health")
8
+ async def health_check() -> Dict[str, str]:
9
+ # Basic health check
10
+ return {
11
+ "status": "ok",
12
+ "model": "loaded",
13
+ "db": "connected"
14
+ }
15
+
16
+ @router.get("/info")
17
+ async def get_info() -> Dict[str, str]:
18
+ return {
19
+ "model_name": "xlm-roberta-base-absa",
20
+ "version": "1.0",
21
+ "supported_languages": "en, hi",
22
+ "max_batch_size": os.getenv("MAX_BATCH_SIZE", "10000")
23
+ }
api/services/__init__.py ADDED
File without changes
api/services/absa_pipeline.py ADDED
@@ -0,0 +1,177 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):
19
+ self.tokenizer = None
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()
api/services/lang_service.py ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fasttext
2
+ import os
3
+ from pathlib import Path
4
+
5
+ class LanguageService:
6
+ def __init__(self):
7
+ # Using a simple heuristic or fasttext if available.
8
+ # For this phase, we'll try to load a fasttext model if it exists,
9
+ # otherwise fallback to simple heuristics.
10
+ self.model = None
11
+ model_path = Path("models/lid.176.ftz")
12
+ if model_path.exists():
13
+ self.model = fasttext.load_model(str(model_path))
14
+
15
+ def detect_language(self, text: str) -> str:
16
+ if self.model:
17
+ predictions = self.model.predict(text.replace("\n", " "), k=1)
18
+ lang = predictions[0][0].replace('__label__', '')
19
+ if lang in ['en', 'hi']:
20
+ return lang
21
+ # Default to en if unknown or other
22
+ return 'en'
23
+ else:
24
+ # Simple heuristic fallback
25
+ hindi_chars = sum(1 for c in text if '\u0900' <= c <= '\u097F')
26
+ if hindi_chars > 0:
27
+ return 'hi'
28
+ return 'en'
29
+
30
+ lang_service = LanguageService()
api/tasks/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from celery import Celery
2
+ import os
3
+
4
+ redis_url = os.getenv("REDIS_URL", "redis://localhost:6379/0")
5
+
6
+ celery_app = Celery(
7
+ "absa_tasks",
8
+ broker=redis_url,
9
+ backend=redis_url.replace("/0", "/1")
10
+ )
11
+
12
+ celery_app.conf.update(
13
+ task_serializer="json",
14
+ result_expires=3600,
15
+ )
api/tasks/batch_tasks.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from api.tasks import celery_app
2
+ from api.services.absa_pipeline import pipeline
3
+ from api.dependencies import SessionLocal
4
+ from api.models.db_models import BatchJob, AspectResult, Review
5
+ import pandas as pd
6
+ import os
7
+ import csv
8
+ from datetime import datetime, timezone
9
+
10
+ @celery_app.task(bind=True)
11
+ def process_batch(self, job_id: str, file_path: str):
12
+ db = SessionLocal()
13
+ try:
14
+ job = db.query(BatchJob).filter(BatchJob.id == job_id).first()
15
+ if not job:
16
+ return
17
+
18
+ job.status = "processing"
19
+ db.commit()
20
+
21
+ # Load CSV
22
+ df = pd.read_csv(file_path)
23
+ if "text" not in df.columns:
24
+ raise ValueError("CSV must contain a 'text' column.")
25
+
26
+ texts = df["text"].tolist()
27
+ batch_size = 32
28
+
29
+ results_dir = "data/results"
30
+ os.makedirs(results_dir, exist_ok=True)
31
+ result_file = f"{results_dir}/{job_id}.csv"
32
+
33
+ processed_count = 0
34
+
35
+ with open(result_file, "w", newline="", encoding="utf-8") as f:
36
+ writer = csv.writer(f)
37
+ writer.writerow(["text", "language", "aspect", "sentiment", "confidence", "start_pos", "end_pos", "processing_time_ms"])
38
+
39
+ for i in range(0, len(texts), batch_size):
40
+ batch_texts = texts[i:i+batch_size]
41
+ predictions = pipeline.predict_batch(batch_texts)
42
+
43
+ for pred in predictions:
44
+ # Save Review
45
+ db_review = Review(
46
+ text=pred.text,
47
+ language=pred.language,
48
+ processing_time_ms=pred.processing_time_ms
49
+ )
50
+ db.add(db_review)
51
+ db.commit()
52
+ db.refresh(db_review)
53
+
54
+ # Save Aspects & CSV
55
+ for asp in pred.aspects:
56
+ db_aspect = AspectResult(
57
+ review_id=db_review.id,
58
+ aspect=asp.aspect,
59
+ sentiment=asp.sentiment,
60
+ confidence=asp.confidence,
61
+ start_pos=asp.start,
62
+ end_pos=asp.end
63
+ )
64
+ db.add(db_aspect)
65
+ writer.writerow([pred.text, pred.language, asp.aspect, asp.sentiment, asp.confidence, asp.start, asp.end, pred.processing_time_ms])
66
+
67
+ if not pred.aspects:
68
+ writer.writerow([pred.text, pred.language, "", "", "", "", "", pred.processing_time_ms])
69
+
70
+ db.commit()
71
+ processed_count += len(batch_texts)
72
+
73
+ if processed_count % 100 == 0 or processed_count == len(texts):
74
+ job.processed = processed_count
75
+ db.commit()
76
+
77
+ job.status = "completed"
78
+ job.completed_at = datetime.now(timezone.utc)
79
+ db.commit()
80
+
81
+ except Exception as e:
82
+ job = db.query(BatchJob).filter(BatchJob.id == job_id).first()
83
+ if job:
84
+ job.status = "failed"
85
+ db.commit()
86
+ raise e
87
+ finally:
88
+ db.close()
dashboard/.env.example ADDED
@@ -0,0 +1 @@
 
 
1
+ VITE_API_URL=http://localhost:8000
dashboard/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
13
+ </html>
dashboard/package.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "dashboard",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "@tanstack/react-query": "^5.0.0",
14
+ "axios": "^1.6.0",
15
+ "lucide-react": "^0.290.0",
16
+ "react": "^18.2.0",
17
+ "react-dom": "^18.2.0",
18
+ "react-dropzone": "^14.2.3",
19
+ "react-hot-toast": "^2.4.1",
20
+ "react-router-dom": "^6.20.0",
21
+ "recharts": "^2.10.0"
22
+ },
23
+ "devDependencies": {
24
+ "@vitejs/plugin-react": "^4.2.0",
25
+ "autoprefixer": "^10.4.16",
26
+ "postcss": "^8.4.31",
27
+ "tailwindcss": "^3.3.5",
28
+ "vite": "^5.0.0"
29
+ }
30
+ }
dashboard/postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
dashboard/public/_redirects ADDED
@@ -0,0 +1 @@
 
 
1
+ /* /index.html 200
dashboard/src/App.jsx ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
3
+ import { Toaster } from 'react-hot-toast'
4
+ import Layout from './components/Layout'
5
+ import Predict from './pages/Predict'
6
+ import Analytics from './pages/Analytics'
7
+ import Monitor from './pages/Monitor'
8
+
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>
21
+ </BrowserRouter>
22
+ )
23
+ }
24
+
25
+ export default App
dashboard/src/api/client.js ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import axios from 'axios'
2
+ import toast from 'react-hot-toast'
3
+ import { API_URL } from '../config'
4
+
5
+ // Create custom axios instance
6
+ const apiClient = axios.create({
7
+ baseURL: API_URL,
8
+ timeout: 30000, // 30 seconds timeout
9
+ })
10
+
11
+ // Add Correlation ID request interceptor
12
+ apiClient.interceptors.request.use((config) => {
13
+ config.headers['X-Correlation-ID'] = crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).substring(7)
14
+ return config
15
+ })
16
+
17
+ // Add retry logic with exponential backoff response interceptor
18
+ apiClient.interceptors.response.use(
19
+ (response) => response,
20
+ async (error) => {
21
+ const config = error.config
22
+
23
+ // Set max retries
24
+ if (!config || !config.retry) {
25
+ config.retry = 3
26
+ config.retryCount = 0
27
+ }
28
+
29
+ if (config.retryCount < config.retry) {
30
+ config.retryCount += 1
31
+ const backoff = Math.pow(2, config.retryCount) * 1000 // exponential backoff
32
+
33
+ console.warn(`Request failed. Retrying... (${config.retryCount}/${config.retry}) in ${backoff}ms`)
34
+
35
+ await new Promise(resolve => setTimeout(resolve, backoff))
36
+ return apiClient(config)
37
+ }
38
+
39
+ return Promise.reject(error)
40
+ }
41
+ )
42
+
43
+ export const api = {
44
+ predict: async (text, language = null) => {
45
+ try {
46
+ const response = await apiClient.post(`/predict`, { text, language })
47
+ return response.data
48
+ } catch (error) {
49
+ toast.error(error.response?.data?.detail || "Prediction failed")
50
+ throw error
51
+ }
52
+ },
53
+ uploadBatch: async (file) => {
54
+ try {
55
+ const form = new FormData()
56
+ form.append("file", file)
57
+ const response = await apiClient.post(`/batch`, form)
58
+ return response.data
59
+ } catch (error) {
60
+ toast.error(error.response?.data?.detail || "Batch upload failed")
61
+ throw error
62
+ }
63
+ },
64
+ getBatchStatus: async (jobId) => {
65
+ const response = await apiClient.get(`/status/${jobId}`)
66
+ return response.data
67
+ },
68
+ getHealth: async () => {
69
+ const response = await apiClient.get(`/health`)
70
+ return response.data
71
+ }
72
+ }
dashboard/src/components/AspectHeatmap.jsx ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, Cell } from 'recharts'
3
+
4
+ export default function AspectHeatmap({ data }) {
5
+ // Mock data if none provided
6
+ const chartData = data || [
7
+ { aspect: 'food', positive: 120, negative: 30, neutral: 10, conflict: 5 },
8
+ { aspect: 'service', positive: 50, negative: 80, neutral: 20, conflict: 15 },
9
+ { aspect: 'price', positive: 40, negative: 60, neutral: 15, conflict: 5 },
10
+ { aspect: 'ambience', positive: 90, negative: 10, neutral: 5, conflict: 2 },
11
+ { aspect: 'staff', positive: 60, negative: 40, neutral: 10, conflict: 8 },
12
+ ]
13
+
14
+ return (
15
+ <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-6 h-96">
16
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Top Aspects by Sentiment</h3>
17
+ <ResponsiveContainer width="100%" height="100%">
18
+ <BarChart
19
+ data={chartData}
20
+ margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
21
+ layout="vertical"
22
+ >
23
+ <CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} horizontal={false} />
24
+ <XAxis type="number" stroke="#6B7280" fontSize={12} />
25
+ <YAxis dataKey="aspect" type="category" stroke="#6B7280" fontSize={12} width={80} />
26
+ <Tooltip
27
+ cursor={{fill: 'rgba(107, 114, 128, 0.1)'}}
28
+ contentStyle={{ backgroundColor: '#1F2937', borderColor: '#374151', color: '#F9FAFB' }}
29
+ />
30
+ <Legend />
31
+ <Bar dataKey="positive" stackId="a" fill="#10B981" />
32
+ <Bar dataKey="negative" stackId="a" fill="#EF4444" />
33
+ <Bar dataKey="neutral" stackId="a" fill="#6B7280" />
34
+ <Bar dataKey="conflict" stackId="a" fill="#F59E0B" />
35
+ </BarChart>
36
+ </ResponsiveContainer>
37
+ </div>
38
+ )
39
+ }
dashboard/src/components/LanguagePie.jsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { PieChart, Pie, Cell, Tooltip, Legend, ResponsiveContainer } from 'recharts'
3
+
4
+ export default function LanguagePie({ data }) {
5
+ // Mock data if none provided
6
+ const chartData = data || [
7
+ { name: 'English', value: 400 },
8
+ { name: 'Hindi', value: 300 },
9
+ { name: 'Hinglish', value: 150 },
10
+ ]
11
+
12
+ const COLORS = ['#3B82F6', '#F97316', '#10B981']
13
+
14
+ return (
15
+ <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-6 h-96">
16
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Language Distribution</h3>
17
+ <ResponsiveContainer width="100%" height="100%">
18
+ <PieChart>
19
+ <Pie
20
+ data={chartData}
21
+ cx="50%"
22
+ cy="45%"
23
+ innerRadius={60}
24
+ outerRadius={100}
25
+ paddingAngle={5}
26
+ dataKey="value"
27
+ stroke="none"
28
+ >
29
+ {chartData.map((entry, index) => (
30
+ <Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
31
+ ))}
32
+ </Pie>
33
+ <Tooltip
34
+ contentStyle={{ backgroundColor: '#1F2937', borderColor: '#374151', color: '#F9FAFB' }}
35
+ itemStyle={{ color: '#F9FAFB' }}
36
+ />
37
+ <Legend verticalAlign="bottom" height={36} />
38
+ </PieChart>
39
+ </ResponsiveContainer>
40
+ </div>
41
+ )
42
+ }
dashboard/src/components/Layout.jsx ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ }
dashboard/src/components/LivePredictor.jsx ADDED
@@ -0,0 +1,189 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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('')
19
+
20
+ const mutation = useMutation({
21
+ mutationFn: (data) => api.predict(data.text, data.language || null),
22
+ })
23
+
24
+ const handlePredict = () => {
25
+ if (!text.trim()) return
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
+ )
189
+ }
dashboard/src/components/Navbar.jsx ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/SentimentChart.jsx ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
3
+
4
+ export default function SentimentChart({ data }) {
5
+ // Mock data if none provided (for initial dev)
6
+ const chartData = data || [
7
+ { name: 'Jan', positive: 400, negative: 240, neutral: 100, conflict: 50 },
8
+ { name: 'Feb', positive: 300, negative: 139, neutral: 200, conflict: 40 },
9
+ { name: 'Mar', positive: 200, negative: 980, neutral: 150, conflict: 100 },
10
+ { name: 'Apr', positive: 278, negative: 390, neutral: 250, conflict: 60 },
11
+ { name: 'May', positive: 189, negative: 480, neutral: 180, conflict: 70 },
12
+ { name: 'Jun', positive: 239, negative: 380, neutral: 210, conflict: 80 },
13
+ { name: 'Jul', positive: 349, negative: 430, neutral: 230, conflict: 90 },
14
+ ]
15
+
16
+ return (
17
+ <div className="bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-gray-200 dark:border-slate-700 p-6 h-96">
18
+ <h3 className="text-sm font-semibold text-gray-900 dark:text-white mb-4">Sentiment Over Time</h3>
19
+ <ResponsiveContainer width="100%" height="100%">
20
+ <LineChart
21
+ data={chartData}
22
+ margin={{ top: 5, right: 30, left: 20, bottom: 5 }}
23
+ >
24
+ <CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.2} />
25
+ <XAxis dataKey="name" stroke="#6B7280" fontSize={12} />
26
+ <YAxis stroke="#6B7280" fontSize={12} />
27
+ <Tooltip
28
+ contentStyle={{ backgroundColor: '#1F2937', borderColor: '#374151', color: '#F9FAFB' }}
29
+ itemStyle={{ color: '#F9FAFB' }}
30
+ />
31
+ <Legend />
32
+ <Line type="monotone" dataKey="positive" stroke="#10B981" strokeWidth={2} dot={{ r: 4 }} activeDot={{ r: 6 }} />
33
+ <Line type="monotone" dataKey="negative" stroke="#EF4444" strokeWidth={2} dot={{ r: 4 }} />
34
+ <Line type="monotone" dataKey="neutral" stroke="#6B7280" strokeWidth={2} dot={{ r: 4 }} />
35
+ <Line type="monotone" dataKey="conflict" stroke="#F59E0B" strokeWidth={2} dot={{ r: 4 }} />
36
+ </LineChart>
37
+ </ResponsiveContainer>
38
+ </div>
39
+ )
40
+ }
dashboard/src/config.js ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ export const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"
2
+ export const POLL_INTERVAL_MS = 2000
3
+ export const MAX_FILE_SIZE_MB = 50
dashboard/src/index.css ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
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
+ }
dashboard/src/main.jsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
+ import App from './App.jsx'
5
+ import './index.css'
6
+
7
+ const queryClient = new QueryClient({
8
+ defaultOptions: {
9
+ queries: {
10
+ refetchOnWindowFocus: false,
11
+ retry: 1,
12
+ },
13
+ },
14
+ })
15
+
16
+ ReactDOM.createRoot(document.getElementById('root')).render(
17
+ <React.StrictMode>
18
+ <QueryClientProvider client={queryClient}>
19
+ <App />
20
+ </QueryClientProvider>
21
+ </React.StrictMode>,
22
+ )
dashboard/src/pages/Analytics.jsx ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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),
30
+ enabled: isPolling && !!jobId,
31
+ refetchInterval: isPolling ? 2000 : false,
32
+ })
33
+
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>
204
+ )
205
+ }
dashboard/src/pages/Monitor.jsx ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>
34
+ <option value={60000}>1m</option>
35
+ <option value={0}>Off</option>
36
+ </select>
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>
125
+ )
126
+ }
dashboard/src/pages/Predict.jsx ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ 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
+ }
dashboard/tailwind.config.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,ts,jsx,tsx}",
6
+ ],
7
+ darkMode: 'class',
8
+ theme: {
9
+ extend: {},
10
+ },
11
+ plugins: [],
12
+ }
dashboard/vercel.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "buildCommand": "npm run build",
3
+ "outputDirectory": "dist",
4
+ "framework": "vite",
5
+ "rewrites": [
6
+ {
7
+ "source": "/api/:path*",
8
+ "destination": "RAILWAY_API_URL/api/:path*"
9
+ }
10
+ ],
11
+ "env": {
12
+ "VITE_API_URL": "RAILWAY_API_URL"
13
+ }
14
+ }
dashboard/vite.config.js ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ // https://vitejs.dev/config/
5
+ export default defineConfig({
6
+ plugins: [react()],
7
+ })
data/demo/demo_single_reviews.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ The phone has an amazing screen but the battery life is terrible.
2
+ I absolutely love the camera quality on this device.
3
+ फोन की बैटरी अच्छी है लेकिन कैमरा बेकार है
4
+ खाना बहुत स्वादिष्ट था, पर सर्विस थोड़ी स्लो थी
5
+ Design mast hai par price bahut high hai
data/demo/sample_reviews.csv ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ text,language
2
+ "The phone has an amazing screen but the battery life is terrible.",en
3
+ "I absolutely love the camera quality on this device.",en
4
+ "Shipping was very fast, but the customer service was useless when I had a question.",en
5
+ "The laptop keyboard feels incredibly cheap, though it runs fast.",en
6
+ "Amazing food, terrible atmosphere.",en
7
+ "Best purchase I made this year. High quality materials.",en
8
+ "The speakers are loud but the bass is non-existent.",en
9
+ "Wait staff was friendly, food was completely cold.",en
10
+ "I like the design but the software has too many bugs.",en
11
+ "Great value for money, highly recommended.",en
12
+ "फोन की बैटरी अच्छी है लेकिन कैमरा बेकार है",hi
13
+ "मुझे इस लैपटॉप की स्क्रीन बहुत पसंद आई",hi
14
+ "डिलीवरी बहुत लेट थी और पैकिंग भी खराब थी",hi
15
+ "खाना बहुत स्वादिष्ट था, पर सर्विस थोड़ी स्लो थी",hi
16
+ "यह प्रोडक्ट पैसे की बर्बादी है",hi
17
+ "Design mast hai par price bahut high hai",hinglish
18
+ "Screen quality ekdum awesome hai bhai",hinglish
19
+ "Customer support ne help nahi ki, very bad experience",hinglish
20
+ "Look and feel to accha hai but performance thik thak hai",hinglish
21
+ "Battery drain jaldi hota hai, overall not good",hinglish
docker-compose.prod.yml ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.9"
2
+ services:
3
+ api:
4
+ image: ghcr.io/YOUR_USERNAME/multilingual-absa/api:latest
5
+ restart: always
6
+ environment:
7
+ - LOG_LEVEL=WARNING
8
+ deploy:
9
+ resources:
10
+ limits:
11
+ memory: 2G
12
+ worker:
13
+ image: ghcr.io/YOUR_USERNAME/multilingual-absa/api:latest
14
+ command: celery -A api.tasks.batch_tasks worker --loglevel=warning
15
+ restart: always
16
+ dashboard:
17
+ restart: always
docker-compose.yml ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: '3.8'
2
+
3
+ services:
4
+ dashboard:
5
+ build:
6
+ context: ./dashboard
7
+ dockerfile: ../docker/Dockerfile.dashboard
8
+ container_name: absa-dashboard
9
+ ports:
10
+ - "3000:80"
11
+ environment:
12
+ - VITE_API_URL=http://api:8000
13
+ depends_on:
14
+ - api
15
+
16
+ api:
17
+ build:
18
+ context: .
19
+ dockerfile: docker/Dockerfile.api
20
+ container_name: absa-api
21
+ ports:
22
+ - "8000:8000"
23
+ environment:
24
+ - DATABASE_URL=postgresql://absa_user:absa_pass@postgres:5432/absa_db
25
+ - REDIS_URL=redis://redis:6379/0
26
+ - MODEL_PATH=models/onnx/
27
+ - MAX_BATCH_SIZE=10000
28
+ depends_on:
29
+ - postgres
30
+ - redis
31
+ volumes:
32
+ - ./models:/app/models
33
+ - ./data:/app/data
34
+
35
+ worker:
36
+ build:
37
+ context: .
38
+ dockerfile: docker/Dockerfile.api
39
+ container_name: absa-worker
40
+ command: ["celery", "-A", "api.tasks", "worker", "--loglevel=info"]
41
+ environment:
42
+ - DATABASE_URL=postgresql://absa_user:absa_pass@postgres:5432/absa_db
43
+ - REDIS_URL=redis://redis:6379/0
44
+ - MODEL_PATH=models/onnx/
45
+ depends_on:
46
+ - postgres
47
+ - redis
48
+ - api
49
+ volumes:
50
+ - ./models:/app/models
51
+ - ./data:/app/data
52
+
53
+ postgres:
54
+ image: postgres:16-alpine
55
+ container_name: absa-postgres
56
+ environment:
57
+ - POSTGRES_USER=absa_user
58
+ - POSTGRES_PASSWORD=absa_pass
59
+ - POSTGRES_DB=absa_db
60
+ ports:
61
+ - "5432:5432"
62
+ volumes:
63
+ - postgres_data:/var/lib/postgresql/data
64
+
65
+ redis:
66
+ image: redis:7-alpine
67
+ container_name: absa-redis
68
+ ports:
69
+ - "6379:6379"
70
+
71
+ prometheus:
72
+ image: prom/prometheus:latest
73
+ volumes:
74
+ - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
75
+ ports: ["9090:9090"]
76
+ command:
77
+ - "--config.file=/etc/prometheus/prometheus.yml"
78
+ - "--storage.tsdb.retention.time=15d"
79
+
80
+ grafana:
81
+ image: grafana/grafana:latest
82
+ ports: ["3001:3000"]
83
+ volumes:
84
+ - ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards
85
+ - ./monitoring/grafana/provisioning:/etc/grafana/provisioning
86
+ environment:
87
+ GF_SECURITY_ADMIN_PASSWORD: admin
88
+ GF_USERS_ALLOW_SIGN_UP: "false"
89
+
90
+ volumes:
91
+ postgres_data:
docker/Dockerfile.api ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Builder
2
+ FROM python:3.11-slim AS builder
3
+
4
+ WORKDIR /app
5
+ COPY requirements.txt .
6
+
7
+ RUN apt-get update && apt-get install -y --no-install-recommends \
8
+ build-essential \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
12
+
13
+ # Stage 2: Runtime
14
+ FROM python:3.11-slim
15
+
16
+ WORKDIR /app
17
+
18
+ # Copy dependencies
19
+ COPY --from=builder /install /usr/local
20
+
21
+ # Copy application code
22
+ COPY api /app/api
23
+ COPY scripts /app/scripts
24
+ COPY .env.example /app/.env
25
+
26
+ # Add a non-root user
27
+ RUN adduser --disabled-password --gecos "" absauser \
28
+ && chown -R absauser /app
29
+
30
+ USER absauser
31
+
32
+ EXPOSE 8000
33
+
34
+ CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"]
docker/Dockerfile.api.prod ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim as builder
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+
6
+ FROM python:3.11-slim as runtime
7
+ WORKDIR /app
8
+ COPY --from=builder /usr/local/lib/python3.11 /usr/local/lib/python3.11
9
+ COPY --from=builder /usr/local/bin /usr/local/bin
10
+ COPY api/ ./api/
11
+ COPY src/ ./src/
12
+ ENV PYTHONPATH=/app
13
+ ENV MODEL_SOURCE=huggingface_hub
14
+ RUN useradd -m appuser && chown -R appuser /app
15
+ USER appuser
16
+ EXPOSE 8000
17
+ CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
docker/Dockerfile.dashboard ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Stage 1: Build
2
+ FROM node:20-alpine AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Install dependencies (only copy package files first)
7
+ COPY dashboard/package.json ./
8
+ RUN npm install
9
+
10
+ # Copy source and build
11
+ COPY dashboard/ ./
12
+ RUN npm run build
13
+
14
+ # Stage 2: Serve
15
+ FROM nginx:alpine
16
+
17
+ # Copy built assets
18
+ COPY --from=builder /app/dist /usr/share/nginx/html
19
+
20
+ # Add custom Nginx configuration
21
+ RUN rm /etc/nginx/conf.d/default.conf
22
+ COPY docker/nginx.dashboard.conf /etc/nginx/conf.d/default.conf
23
+
24
+ EXPOSE 80
25
+
26
+ CMD ["nginx", "-g", "daemon off;"]