Spaces:
Runtime error
Runtime error
Aryan Mishra commited on
Commit ·
1130076
1
Parent(s): ec272a0
feat: Phase 8 - final evaluation and portfolio polish
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .DS_Store +0 -0
- .env.example +5 -0
- .env.railway +6 -0
- .github/workflows/ci.yml +38 -0
- .github/workflows/deploy.yml +29 -0
- .github/workflows/drift_check.yml +20 -0
- .gitignore +1 -0
- README.md +80 -39
- api/__init__.py +0 -0
- api/dependencies.py +18 -0
- api/main.py +36 -0
- api/middleware/__init__.py +0 -0
- api/middleware/metrics.py +12 -0
- api/routers/__init__.py +0 -0
- api/routers/predict.py +113 -0
- api/routers/results.py +23 -0
- api/services/__init__.py +0 -0
- api/services/absa_pipeline.py +177 -0
- api/services/lang_service.py +30 -0
- api/tasks/__init__.py +15 -0
- api/tasks/batch_tasks.py +88 -0
- dashboard/.env.example +1 -0
- dashboard/index.html +13 -0
- dashboard/package.json +30 -0
- dashboard/postcss.config.js +6 -0
- dashboard/public/_redirects +1 -0
- dashboard/src/App.jsx +25 -0
- dashboard/src/api/client.js +72 -0
- dashboard/src/components/AspectHeatmap.jsx +39 -0
- dashboard/src/components/LanguagePie.jsx +42 -0
- dashboard/src/components/Layout.jsx +24 -0
- dashboard/src/components/LivePredictor.jsx +189 -0
- dashboard/src/components/Navbar.jsx +120 -0
- dashboard/src/components/SentimentChart.jsx +40 -0
- dashboard/src/config.js +3 -0
- dashboard/src/index.css +12 -0
- dashboard/src/main.jsx +22 -0
- dashboard/src/pages/Analytics.jsx +205 -0
- dashboard/src/pages/Monitor.jsx +126 -0
- dashboard/src/pages/Predict.jsx +18 -0
- dashboard/tailwind.config.js +12 -0
- dashboard/vercel.json +14 -0
- dashboard/vite.config.js +7 -0
- data/demo/demo_single_reviews.txt +5 -0
- data/demo/sample_reviews.csv +21 -0
- docker-compose.prod.yml +17 -0
- docker-compose.yml +91 -0
- docker/Dockerfile.api +34 -0
- docker/Dockerfile.api.prod +17 -0
- 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
|
| 2 |
|
| 3 |
-
Aspect-Based Sentiment Analysis
|
| 4 |
|
| 5 |
-
##
|
| 6 |
-
|
| 7 |
|
| 8 |
-
##
|
| 9 |
-
|
| 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 |
-
##
|
| 17 |
-
|
| 18 |
-
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
##
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
##
|
| 38 |
-
``
|
| 39 |
-
# Clone the repository
|
| 40 |
-
git clone https://github.com/your-org/multilingual-absa.git
|
| 41 |
-
cd multilingual-absa
|
| 42 |
|
| 43 |
-
#
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
| 46 |
-
#
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
```
|
| 49 |
|
| 50 |
-
##
|
| 51 |
-
-
|
| 52 |
-
-
|
| 53 |
-
-
|
| 54 |
-
- Macro-F1 is the primary evaluation metric (not accuracy)
|
| 55 |
-
- ONNX export required before any model goes to the API
|
| 56 |
|
| 57 |
-
##
|
| 58 |
-
|
|
|
|
| 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;"]
|