Spaces:
Sleeping
Sleeping
CHAIMA commited on
Commit ·
8d8f5f1
0
Parent(s):
Initial clean commit
Browse files- .gitignore +61 -0
- Dockerfile +41 -0
- README.md +34 -0
- backend/call_center.db +0 -0
- backend/database.py +35 -0
- backend/main.py +127 -0
- backend/requirements.txt +9 -0
- backend/seed.py +53 -0
- backend/services/keywords.py +58 -0
- backend/services/sentiment.py +48 -0
- backend/services/transcription.py +29 -0
- backend/test_dziri.py +4 -0
- frontend/.gitignore +24 -0
- frontend/README.md +16 -0
- frontend/eslint.config.js +21 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +37 -0
- frontend/postcss.config.js +6 -0
- frontend/public/favicon.svg +1 -0
- frontend/public/icons.svg +24 -0
- frontend/src/App.css +184 -0
- frontend/src/App.jsx +24 -0
- frontend/src/assets/hero.png +0 -0
- frontend/src/assets/react.svg +1 -0
- frontend/src/assets/vite.svg +1 -0
- frontend/src/components/layout/Layout.jsx +45 -0
- frontend/src/components/layout/Sidebar.jsx +77 -0
- frontend/src/components/ui/badge.jsx +26 -0
- frontend/src/components/ui/button.jsx +30 -0
- frontend/src/components/ui/card.jsx +24 -0
- frontend/src/components/ui/progress.jsx +18 -0
- frontend/src/index.css +54 -0
- frontend/src/main.jsx +10 -0
- frontend/src/pages/DashboardPage.jsx +289 -0
- frontend/src/pages/HistoryPage.jsx +137 -0
- frontend/src/pages/ResultPage.jsx +172 -0
- frontend/src/pages/UploadPage.jsx +188 -0
- frontend/tailwind.config.js +22 -0
- frontend/vite.config.js +13 -0
.gitignore
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
venv/
|
| 3 |
+
.venv/
|
| 4 |
+
env/
|
| 5 |
+
.env/
|
| 6 |
+
bin/
|
| 7 |
+
lib/
|
| 8 |
+
share/
|
| 9 |
+
include/
|
| 10 |
+
__pycache__/
|
| 11 |
+
*.py[cod]
|
| 12 |
+
*$py.class
|
| 13 |
+
*.so
|
| 14 |
+
.Python
|
| 15 |
+
build/
|
| 16 |
+
develop-eggs/
|
| 17 |
+
dist/
|
| 18 |
+
downloads/
|
| 19 |
+
eggs/
|
| 20 |
+
.eggs/
|
| 21 |
+
lib64/
|
| 22 |
+
parts/
|
| 23 |
+
sdist/
|
| 24 |
+
var/
|
| 25 |
+
wheels/
|
| 26 |
+
*.egg-info/
|
| 27 |
+
.installed.cfg
|
| 28 |
+
*.egg
|
| 29 |
+
|
| 30 |
+
# Node
|
| 31 |
+
node_modules/
|
| 32 |
+
npm-debug.log*
|
| 33 |
+
yarn-debug.log*
|
| 34 |
+
yarn-error.log*
|
| 35 |
+
.pnpm-debug.log*
|
| 36 |
+
.pnpm-error.log*
|
| 37 |
+
frontend/dist/
|
| 38 |
+
frontend/node_modules/
|
| 39 |
+
|
| 40 |
+
# OS
|
| 41 |
+
.DS_Store
|
| 42 |
+
.DS_Store?
|
| 43 |
+
._*
|
| 44 |
+
.Spotlight-V100
|
| 45 |
+
.Trashes
|
| 46 |
+
ehthumbs.db
|
| 47 |
+
Thumbs.db
|
| 48 |
+
|
| 49 |
+
# Databases
|
| 50 |
+
*.sqlite3
|
| 51 |
+
backend/database.db
|
| 52 |
+
|
| 53 |
+
# Uploads
|
| 54 |
+
backend/uploads/*
|
| 55 |
+
!backend/uploads/.gitkeep
|
| 56 |
+
|
| 57 |
+
# IDEs
|
| 58 |
+
.vscode/
|
| 59 |
+
.idea/
|
| 60 |
+
*.swp
|
| 61 |
+
*.swo
|
Dockerfile
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Stage 1: Build Frontend
|
| 2 |
+
FROM node:20-slim AS frontend-builder
|
| 3 |
+
WORKDIR /app/frontend
|
| 4 |
+
COPY frontend/package*.json ./
|
| 5 |
+
RUN npm install
|
| 6 |
+
COPY frontend/ ./
|
| 7 |
+
RUN npm run build
|
| 8 |
+
|
| 9 |
+
# Stage 2: Backend & Final Image
|
| 10 |
+
FROM python:3.9-slim
|
| 11 |
+
|
| 12 |
+
# Install system dependencies (FFmpeg is required for Whisper)
|
| 13 |
+
RUN apt-get update && apt-get install -y \
|
| 14 |
+
ffmpeg \
|
| 15 |
+
build-essential \
|
| 16 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 17 |
+
|
| 18 |
+
# Set working directory
|
| 19 |
+
WORKDIR /app
|
| 20 |
+
|
| 21 |
+
# Copy backend requirements first for caching
|
| 22 |
+
COPY backend/requirements.txt .
|
| 23 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 24 |
+
|
| 25 |
+
# Copy backend code
|
| 26 |
+
COPY backend /app/backend
|
| 27 |
+
|
| 28 |
+
# Copy built frontend from Stage 1
|
| 29 |
+
COPY --from=frontend-builder /app/frontend/dist /app/frontend/dist
|
| 30 |
+
|
| 31 |
+
# Set environment variables
|
| 32 |
+
ENV PYTHONUNBUFFERED=1
|
| 33 |
+
ENV PORT=7860
|
| 34 |
+
|
| 35 |
+
# Expose the port (Hugging Face uses 7860 by default)
|
| 36 |
+
EXPOSE 7860
|
| 37 |
+
|
| 38 |
+
# Command to run the application
|
| 39 |
+
# We run from the backend directory so that imports work correctly
|
| 40 |
+
WORKDIR /app/backend
|
| 41 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: HEAREM - SentiCall AT
|
| 3 |
+
emoji: 🎙️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: orange
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# HEAREM (SentiCall AT)
|
| 12 |
+
### Algerian Call Center Sentiment Analysis Platform
|
| 13 |
+
|
| 14 |
+
HEAREM is an enterprise-grade AI platform designed for **Algérie Télécom** to analyze customer interactions. It leverages state-of-the-art NLP models to transcribe Algerian Arabic (Darija) and French, detect sentiment, and extract key business topics.
|
| 15 |
+
|
| 16 |
+
## 🚀 Features
|
| 17 |
+
- **Multi-lingual Transcription**: Powered by OpenAI Whisper for accurate speech-to-text.
|
| 18 |
+
- **Sentiment Analysis**: Real-time detection of customer emotions.
|
| 19 |
+
- **Topic Classification**: Automated tagging of business-related keywords.
|
| 20 |
+
- **Enterprise Dashboard**: Interactive analytics for managers and agents.
|
| 21 |
+
|
| 22 |
+
## 🛠 Deployment on Hugging Face Spaces
|
| 23 |
+
This app is containerized using Docker and optimized for Hugging Face Spaces.
|
| 24 |
+
|
| 25 |
+
1. **Create a New Space** on Hugging Face.
|
| 26 |
+
2. Select **Docker** as the SDK (Blank template).
|
| 27 |
+
3. Push the repository contents to the Space.
|
| 28 |
+
4. The build process will automatically:
|
| 29 |
+
- Build the React frontend.
|
| 30 |
+
- Set up the Python FastAPI backend.
|
| 31 |
+
- Serve the full-stack application on port 7860.
|
| 32 |
+
|
| 33 |
+
---
|
| 34 |
+
*Developed for Algérie Télécom Sentiment Analysis MVP.*
|
backend/call_center.db
ADDED
|
Binary file (32.8 kB). View file
|
|
|
backend/database.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from sqlalchemy import create_engine, Column, Integer, String, Float, DateTime
|
| 3 |
+
from sqlalchemy.orm import declarative_base
|
| 4 |
+
from sqlalchemy.orm import sessionmaker
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
|
| 7 |
+
DATABASE_URL = "sqlite:///./call_center.db"
|
| 8 |
+
|
| 9 |
+
engine = create_engine(
|
| 10 |
+
DATABASE_URL, connect_args={"check_same_thread": False}
|
| 11 |
+
)
|
| 12 |
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
| 13 |
+
|
| 14 |
+
Base = declarative_base()
|
| 15 |
+
|
| 16 |
+
class CallRecord(Base):
|
| 17 |
+
__tablename__ = "call_records"
|
| 18 |
+
|
| 19 |
+
id = Column(Integer, primary_key=True, index=True)
|
| 20 |
+
filename = Column(String, index=True)
|
| 21 |
+
transcript = Column(String)
|
| 22 |
+
sentiment = Column(String) # Positive, Neutral, Negative
|
| 23 |
+
confidence = Column(Float)
|
| 24 |
+
keywords = Column(String) # comma-separated string
|
| 25 |
+
agent_name = Column(String, default="Agent Inconnu") # Traceability
|
| 26 |
+
created_at = Column(DateTime, default=datetime.utcnow)
|
| 27 |
+
|
| 28 |
+
Base.metadata.create_all(bind=engine)
|
| 29 |
+
|
| 30 |
+
def get_db():
|
| 31 |
+
db = SessionLocal()
|
| 32 |
+
try:
|
| 33 |
+
yield db
|
| 34 |
+
finally:
|
| 35 |
+
db.close()
|
backend/main.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import shutil
|
| 3 |
+
from fastapi import FastAPI, UploadFile, File, Form, Depends, HTTPException
|
| 4 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
import uuid
|
| 8 |
+
|
| 9 |
+
from database import engine, Base, get_db, CallRecord
|
| 10 |
+
from services.transcription import extract_transcript
|
| 11 |
+
from services.sentiment import analyze_sentiment
|
| 12 |
+
from services.keywords import extract_keywords
|
| 13 |
+
|
| 14 |
+
app = FastAPI(title="Algerian Call Center Sentiment MVP")
|
| 15 |
+
|
| 16 |
+
# Allow CORS for local frontend development
|
| 17 |
+
app.add_middleware(
|
| 18 |
+
CORSMiddleware,
|
| 19 |
+
allow_origins=["*"],
|
| 20 |
+
allow_credentials=True,
|
| 21 |
+
allow_methods=["*"],
|
| 22 |
+
allow_headers=["*"],
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
UPLOAD_DIR = "uploads"
|
| 26 |
+
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
| 27 |
+
|
| 28 |
+
@app.post("/analyze-call")
|
| 29 |
+
async def analyze_call(
|
| 30 |
+
file: UploadFile = File(...),
|
| 31 |
+
agent_name: str = Form("user"),
|
| 32 |
+
db: Session = Depends(get_db)
|
| 33 |
+
):
|
| 34 |
+
"""
|
| 35 |
+
Upload an audio file, process it through the pipeline, and save to DB.
|
| 36 |
+
"""
|
| 37 |
+
if not file.filename.endswith(('.wav', '.mp3', '.ogg', '.m4a')):
|
| 38 |
+
raise HTTPException(status_code=400, detail="Invalid audio file format.")
|
| 39 |
+
|
| 40 |
+
# Generate unique filename to avoid collisions
|
| 41 |
+
file_id = str(uuid.uuid4())
|
| 42 |
+
extension = os.path.splitext(file.filename)[1]
|
| 43 |
+
safe_filename = f"{file_id}{extension}"
|
| 44 |
+
file_path = os.path.join(UPLOAD_DIR, safe_filename)
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
# Save uploaded file
|
| 48 |
+
with open(file_path, "wb") as buffer:
|
| 49 |
+
shutil.copyfileobj(file.file, buffer)
|
| 50 |
+
|
| 51 |
+
# 1. Transcribe Audio
|
| 52 |
+
transcript = extract_transcript(file_path)
|
| 53 |
+
|
| 54 |
+
# 2. Analyze Sentiment
|
| 55 |
+
sentiment_result = analyze_sentiment(transcript)
|
| 56 |
+
sentiment_label = sentiment_result['label']
|
| 57 |
+
confidence = sentiment_result['score']
|
| 58 |
+
|
| 59 |
+
# 3. Extract Keywords
|
| 60 |
+
keywords = extract_keywords(transcript)
|
| 61 |
+
|
| 62 |
+
# 4. Save to Database
|
| 63 |
+
new_record = CallRecord(
|
| 64 |
+
filename=file.filename,
|
| 65 |
+
transcript=transcript,
|
| 66 |
+
sentiment=sentiment_label,
|
| 67 |
+
confidence=confidence,
|
| 68 |
+
keywords=str(keywords),
|
| 69 |
+
agent_name=agent_name
|
| 70 |
+
)
|
| 71 |
+
db.add(new_record)
|
| 72 |
+
db.commit()
|
| 73 |
+
db.refresh(new_record)
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"success": True,
|
| 77 |
+
"data": {
|
| 78 |
+
"id": new_record.id,
|
| 79 |
+
"filename": new_record.filename,
|
| 80 |
+
"transcript": new_record.transcript,
|
| 81 |
+
"sentiment": new_record.sentiment,
|
| 82 |
+
"confidence": new_record.confidence,
|
| 83 |
+
"keywords": new_record.keywords,
|
| 84 |
+
"created_at": new_record.created_at
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
except Exception as e:
|
| 89 |
+
raise HTTPException(status_code=500, detail=str(e))
|
| 90 |
+
finally:
|
| 91 |
+
# Optionally clean up the file after processing,
|
| 92 |
+
# or keep it if playback is a desired feature later
|
| 93 |
+
pass
|
| 94 |
+
|
| 95 |
+
from fastapi.staticfiles import StaticFiles
|
| 96 |
+
from fastapi.responses import FileResponse
|
| 97 |
+
|
| 98 |
+
# ... existing code ...
|
| 99 |
+
|
| 100 |
+
@app.get("/history")
|
| 101 |
+
def get_history(db: Session = Depends(get_db)):
|
| 102 |
+
"""
|
| 103 |
+
Return all analyzed calls from history.
|
| 104 |
+
"""
|
| 105 |
+
records = db.query(CallRecord).order_by(CallRecord.created_at.desc()).all()
|
| 106 |
+
return records
|
| 107 |
+
|
| 108 |
+
@app.get("/call/{call_id}")
|
| 109 |
+
def get_call(call_id: int, db: Session = Depends(get_db)):
|
| 110 |
+
"""
|
| 111 |
+
Return details of a specific call.
|
| 112 |
+
"""
|
| 113 |
+
record = db.query(CallRecord).filter(CallRecord.id == call_id).first()
|
| 114 |
+
if not record:
|
| 115 |
+
raise HTTPException(status_code=404, detail="Call not found")
|
| 116 |
+
return record
|
| 117 |
+
|
| 118 |
+
# Mount static files (ensure frontend/dist exists)
|
| 119 |
+
# In production, we'll build the frontend into this folder.
|
| 120 |
+
FRONTEND_PATH = "../frontend/dist"
|
| 121 |
+
if os.path.exists(FRONTEND_PATH):
|
| 122 |
+
app.mount("/", StaticFiles(directory=FRONTEND_PATH, html=True), name="frontend")
|
| 123 |
+
|
| 124 |
+
# Catch-all route to serve index.html for React Router
|
| 125 |
+
@app.get("/{full_path:path}")
|
| 126 |
+
async def serve_react_app(full_path: str):
|
| 127 |
+
return FileResponse(os.path.join(FRONTEND_PATH, "index.html"))
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.104.1
|
| 2 |
+
uvicorn==0.24.0
|
| 3 |
+
python-multipart==0.0.6
|
| 4 |
+
openai-whisper
|
| 5 |
+
transformers==4.35.2
|
| 6 |
+
torch>=2.0.0
|
| 7 |
+
scikit-learn==1.3.2
|
| 8 |
+
SQLAlchemy==2.0.23
|
| 9 |
+
pydantic==2.5.2
|
backend/seed.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import random
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from sqlalchemy.orm import Session
|
| 4 |
+
from database import engine, Base, SessionLocal, CallRecord
|
| 5 |
+
import uuid
|
| 6 |
+
|
| 7 |
+
# Initialize DB
|
| 8 |
+
Base.metadata.create_all(bind=engine)
|
| 9 |
+
db = SessionLocal()
|
| 10 |
+
|
| 11 |
+
# Clear existing if needed (optional)
|
| 12 |
+
# db.query(CallRecord).delete()
|
| 13 |
+
|
| 14 |
+
issues = ["Coupure internet", "Lenteur ADSL", "Facture non payée", "Modem Fibre", "Demande 4G", "Changement de mot de passe", "Renseignement", "Réclamation technique"]
|
| 15 |
+
sentiments = ["Positive", "Neutral", "Negative"]
|
| 16 |
+
weights = [0.15, 0.25, 0.60] # Heavy on negative/neutral for realism in a support center
|
| 17 |
+
agents = ["Amine B.", "Sarah M.", "Karim D.", "Nadia Y.", "Walid T."]
|
| 18 |
+
|
| 19 |
+
def get_random_date(days_back=30):
|
| 20 |
+
start_date = datetime.now() - timedelta(days=days_back)
|
| 21 |
+
random_days = random.random() * days_back
|
| 22 |
+
return start_date + timedelta(days=random_days)
|
| 23 |
+
|
| 24 |
+
def generate_mock_data(num_records=50):
|
| 25 |
+
print(f"Generating {num_records} mock call records for Algérie Télécom context...")
|
| 26 |
+
|
| 27 |
+
for _ in range(num_records):
|
| 28 |
+
sentiment = random.choices(sentiments, weights=weights)[0]
|
| 29 |
+
issue = random.choice(issues)
|
| 30 |
+
agent = random.choice(agents)
|
| 31 |
+
|
| 32 |
+
# Adjust confidence
|
| 33 |
+
confidence = random.uniform(0.75, 0.99)
|
| 34 |
+
|
| 35 |
+
# Transcript mock
|
| 36 |
+
transcript = f"[Transcript simulé] Le client appelle concernant: {issue}. L'agent {agent} a répondu."
|
| 37 |
+
|
| 38 |
+
record = CallRecord(
|
| 39 |
+
filename=f"appel_at_{uuid.uuid4().hex[:6]}.wav",
|
| 40 |
+
transcript=transcript,
|
| 41 |
+
sentiment=sentiment,
|
| 42 |
+
confidence=confidence,
|
| 43 |
+
keywords=issue,
|
| 44 |
+
agent_name=agent,
|
| 45 |
+
created_at=get_random_date()
|
| 46 |
+
)
|
| 47 |
+
db.add(record)
|
| 48 |
+
|
| 49 |
+
db.commit()
|
| 50 |
+
print("Mock data generated successfully!")
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
generate_mock_data(75) # Generate 75 mock calls
|
backend/services/keywords.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transformers import pipeline
|
| 2 |
+
|
| 3 |
+
# Use a powerful multilingual zero-shot classification model
|
| 4 |
+
# This model will download on first run (~500MB)
|
| 5 |
+
try:
|
| 6 |
+
print("Loading Zero-Shot Topic Classification model...")
|
| 7 |
+
topic_classifier = pipeline(
|
| 8 |
+
"zero-shot-classification",
|
| 9 |
+
model="MoritzLaurer/mDeBERTa-v3-base-mnli-xnli"
|
| 10 |
+
)
|
| 11 |
+
except Exception as e:
|
| 12 |
+
print(f"Error loading Topic model: {e}")
|
| 13 |
+
topic_classifier = None
|
| 14 |
+
|
| 15 |
+
# Predefined categories for Algérie Télécom context
|
| 16 |
+
AT_TOPICS = [
|
| 17 |
+
"Coupure Internet",
|
| 18 |
+
"Problème Modem",
|
| 19 |
+
"Facturation",
|
| 20 |
+
"Renseignement Technique",
|
| 21 |
+
"Fibre Optique",
|
| 22 |
+
"Réseau Mobile 4G",
|
| 23 |
+
"Réclamation Service Client",
|
| 24 |
+
"Demande de Ligne"
|
| 25 |
+
]
|
| 26 |
+
|
| 27 |
+
def extract_keywords(text: str, top_k: int = 2) -> str:
|
| 28 |
+
"""
|
| 29 |
+
Extracts the most relevant topics using Zero-Shot Classification.
|
| 30 |
+
Returns a comma-separated string of the most likely topics.
|
| 31 |
+
"""
|
| 32 |
+
if not text.strip() or not topic_classifier:
|
| 33 |
+
return ""
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
# Perform zero-shot classification
|
| 37 |
+
result = topic_classifier(
|
| 38 |
+
text,
|
| 39 |
+
candidate_labels=AT_TOPICS,
|
| 40 |
+
multi_label=True
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# Get labels with high confidence (e.g., > 0.4)
|
| 44 |
+
top_labels = []
|
| 45 |
+
for label, score in zip(result['labels'], result['scores']):
|
| 46 |
+
if score > 0.4:
|
| 47 |
+
top_labels.append(label)
|
| 48 |
+
if len(top_labels) >= top_k:
|
| 49 |
+
break
|
| 50 |
+
|
| 51 |
+
# If none above threshold, take the top 1
|
| 52 |
+
if not top_labels and result['labels']:
|
| 53 |
+
top_labels = [result['labels'][0]]
|
| 54 |
+
|
| 55 |
+
return ", ".join(top_labels)
|
| 56 |
+
except Exception as e:
|
| 57 |
+
print(f"Error classifying topics: {e}")
|
| 58 |
+
return ""
|
backend/services/sentiment.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transformers import pipeline
|
| 2 |
+
|
| 3 |
+
# We use alger-ia/dziribert_sentiment if it's available, otherwise fallback to a generic multilingual
|
| 4 |
+
# sentiment model that performs reasonably well for demonstration.
|
| 5 |
+
# alger-ia/dziribert is the base model.
|
| 6 |
+
try:
|
| 7 |
+
print("Loading DziriBERT sentiment model...")
|
| 8 |
+
sentiment_pipeline = pipeline(
|
| 9 |
+
"sentiment-analysis",
|
| 10 |
+
model="alger-ia/dziribert_sentiment"
|
| 11 |
+
)
|
| 12 |
+
except Exception as e:
|
| 13 |
+
print(f"Error loading DziriBERT model, using generic fallback: {e}")
|
| 14 |
+
sentiment_pipeline = pipeline("sentiment-analysis")
|
| 15 |
+
|
| 16 |
+
def analyze_sentiment(text: str) -> dict:
|
| 17 |
+
"""
|
| 18 |
+
Analyzes the sentiment of the transcribed text.
|
| 19 |
+
Returns a dictionary with 'label' (Positive, Neutral, Negative) and 'score'.
|
| 20 |
+
"""
|
| 21 |
+
if not text.strip():
|
| 22 |
+
return {"label": "Neutral", "score": 1.0}
|
| 23 |
+
|
| 24 |
+
# Truncate text if it's too long for the BERT model
|
| 25 |
+
max_length = 512
|
| 26 |
+
truncated_text = text[:max_length]
|
| 27 |
+
|
| 28 |
+
result = sentiment_pipeline(truncated_text)[0]
|
| 29 |
+
|
| 30 |
+
# Map the output to Positive/Neutral/Negative
|
| 31 |
+
# camelbert returns 'positive', 'neutral', 'negative'
|
| 32 |
+
label = result['label'].capitalize()
|
| 33 |
+
|
| 34 |
+
# Ensure it's one of the required labels
|
| 35 |
+
if label not in ["Positive", "Neutral", "Negative"]:
|
| 36 |
+
if "pos" in label.lower():
|
| 37 |
+
label = "Positive"
|
| 38 |
+
elif "neg" in label.lower():
|
| 39 |
+
label = "Negative"
|
| 40 |
+
else:
|
| 41 |
+
label = "Neutral"
|
| 42 |
+
|
| 43 |
+
score = float(result['score'])
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
"label": label,
|
| 47 |
+
"score": round(score, 4)
|
| 48 |
+
}
|
backend/services/transcription.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import whisper
|
| 2 |
+
import os
|
| 3 |
+
|
| 4 |
+
# Upgraded to "large-v3" for maximum accuracy on code-switching (Arabic/French).
|
| 5 |
+
MODEL_SIZE = "large-v3"
|
| 6 |
+
|
| 7 |
+
print(f"Loading whisper model ({MODEL_SIZE})...")
|
| 8 |
+
# Load model (downloads on first run)
|
| 9 |
+
model = whisper.load_model(MODEL_SIZE)
|
| 10 |
+
|
| 11 |
+
def extract_transcript(audio_path: str) -> str:
|
| 12 |
+
"""
|
| 13 |
+
Transcribes the audio file and returns the combined text.
|
| 14 |
+
"""
|
| 15 |
+
print(f"Transcribing {audio_path}...")
|
| 16 |
+
|
| 17 |
+
# Optimized prompt for code-switching (Arabic and French scripts)
|
| 18 |
+
# This guides Whisper to use Latin for French and Arabic script for Darija.
|
| 19 |
+
darija_prompt = "Bonjour, l'appel d'aujourd'hui est pour une réclamation. كيفاش نقدر نعاونك؟ واش كاين مشكل؟ Merci beaucoup."
|
| 20 |
+
|
| 21 |
+
result = model.transcribe(
|
| 22 |
+
audio_path,
|
| 23 |
+
initial_prompt=darija_prompt
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
# Extract language info for logging if needed
|
| 27 |
+
print(f"Detected language: {result.get('language', 'unknown')}")
|
| 28 |
+
|
| 29 |
+
return result["text"].strip()
|
backend/test_dziri.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from transformers import pipeline
|
| 2 |
+
p = pipeline("sentiment-analysis", model="alger-ia/dziribert_sentiment")
|
| 3 |
+
print(p("Rani mlih hamdoullah"))
|
| 4 |
+
print(p("Connexion raïha fiha, connexion mita"))
|
frontend/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
frontend/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
| 9 |
+
|
| 10 |
+
## React Compiler
|
| 11 |
+
|
| 12 |
+
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
| 13 |
+
|
| 14 |
+
## Expanding the ESLint configuration
|
| 15 |
+
|
| 16 |
+
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
frontend/eslint.config.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 6 |
+
|
| 7 |
+
export default defineConfig([
|
| 8 |
+
globalIgnores(['dist']),
|
| 9 |
+
{
|
| 10 |
+
files: ['**/*.{js,jsx}'],
|
| 11 |
+
extends: [
|
| 12 |
+
js.configs.recommended,
|
| 13 |
+
reactHooks.configs.flat.recommended,
|
| 14 |
+
reactRefresh.configs.vite,
|
| 15 |
+
],
|
| 16 |
+
languageOptions: {
|
| 17 |
+
globals: globals.browser,
|
| 18 |
+
parserOptions: { ecmaFeatures: { jsx: true } },
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
])
|
frontend/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="/favicon.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>frontend</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
frontend/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
frontend/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "frontend",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"axios": "^1.16.1",
|
| 14 |
+
"clsx": "^2.1.1",
|
| 15 |
+
"lucide-react": "^1.16.0",
|
| 16 |
+
"react": "^19.2.6",
|
| 17 |
+
"react-dom": "^19.2.6",
|
| 18 |
+
"react-router-dom": "^7.15.1",
|
| 19 |
+
"recharts": "^3.8.1",
|
| 20 |
+
"tailwind-merge": "^3.6.0"
|
| 21 |
+
},
|
| 22 |
+
"devDependencies": {
|
| 23 |
+
"@eslint/js": "^10.0.1",
|
| 24 |
+
"@types/node": "^25.8.0",
|
| 25 |
+
"@types/react": "^19.2.14",
|
| 26 |
+
"@types/react-dom": "^19.2.3",
|
| 27 |
+
"@vitejs/plugin-react": "^6.0.1",
|
| 28 |
+
"autoprefixer": "^10.5.0",
|
| 29 |
+
"eslint": "^10.3.0",
|
| 30 |
+
"eslint-plugin-react-hooks": "^7.1.1",
|
| 31 |
+
"eslint-plugin-react-refresh": "^0.5.2",
|
| 32 |
+
"globals": "^17.6.0",
|
| 33 |
+
"postcss": "^8.5.14",
|
| 34 |
+
"tailwindcss": "^3.4.19",
|
| 35 |
+
"vite": "^8.0.12"
|
| 36 |
+
}
|
| 37 |
+
}
|
frontend/postcss.config.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default {
|
| 2 |
+
plugins: {
|
| 3 |
+
tailwindcss: {},
|
| 4 |
+
autoprefixer: {},
|
| 5 |
+
},
|
| 6 |
+
}
|
frontend/public/favicon.svg
ADDED
|
|
frontend/public/icons.svg
ADDED
|
|
frontend/src/App.css
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.counter {
|
| 2 |
+
font-size: 16px;
|
| 3 |
+
padding: 5px 10px;
|
| 4 |
+
border-radius: 5px;
|
| 5 |
+
color: var(--accent);
|
| 6 |
+
background: var(--accent-bg);
|
| 7 |
+
border: 2px solid transparent;
|
| 8 |
+
transition: border-color 0.3s;
|
| 9 |
+
margin-bottom: 24px;
|
| 10 |
+
|
| 11 |
+
&:hover {
|
| 12 |
+
border-color: var(--accent-border);
|
| 13 |
+
}
|
| 14 |
+
&:focus-visible {
|
| 15 |
+
outline: 2px solid var(--accent);
|
| 16 |
+
outline-offset: 2px;
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.hero {
|
| 21 |
+
position: relative;
|
| 22 |
+
|
| 23 |
+
.base,
|
| 24 |
+
.framework,
|
| 25 |
+
.vite {
|
| 26 |
+
inset-inline: 0;
|
| 27 |
+
margin: 0 auto;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.base {
|
| 31 |
+
width: 170px;
|
| 32 |
+
position: relative;
|
| 33 |
+
z-index: 0;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.framework,
|
| 37 |
+
.vite {
|
| 38 |
+
position: absolute;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.framework {
|
| 42 |
+
z-index: 1;
|
| 43 |
+
top: 34px;
|
| 44 |
+
height: 28px;
|
| 45 |
+
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
| 46 |
+
scale(1.4);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.vite {
|
| 50 |
+
z-index: 0;
|
| 51 |
+
top: 107px;
|
| 52 |
+
height: 26px;
|
| 53 |
+
width: auto;
|
| 54 |
+
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
| 55 |
+
scale(0.8);
|
| 56 |
+
}
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
#center {
|
| 60 |
+
display: flex;
|
| 61 |
+
flex-direction: column;
|
| 62 |
+
gap: 25px;
|
| 63 |
+
place-content: center;
|
| 64 |
+
place-items: center;
|
| 65 |
+
flex-grow: 1;
|
| 66 |
+
|
| 67 |
+
@media (max-width: 1024px) {
|
| 68 |
+
padding: 32px 20px 24px;
|
| 69 |
+
gap: 18px;
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
#next-steps {
|
| 74 |
+
display: flex;
|
| 75 |
+
border-top: 1px solid var(--border);
|
| 76 |
+
text-align: left;
|
| 77 |
+
|
| 78 |
+
& > div {
|
| 79 |
+
flex: 1 1 0;
|
| 80 |
+
padding: 32px;
|
| 81 |
+
@media (max-width: 1024px) {
|
| 82 |
+
padding: 24px 20px;
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.icon {
|
| 87 |
+
margin-bottom: 16px;
|
| 88 |
+
width: 22px;
|
| 89 |
+
height: 22px;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
@media (max-width: 1024px) {
|
| 93 |
+
flex-direction: column;
|
| 94 |
+
text-align: center;
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
#docs {
|
| 99 |
+
border-right: 1px solid var(--border);
|
| 100 |
+
|
| 101 |
+
@media (max-width: 1024px) {
|
| 102 |
+
border-right: none;
|
| 103 |
+
border-bottom: 1px solid var(--border);
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
#next-steps ul {
|
| 108 |
+
list-style: none;
|
| 109 |
+
padding: 0;
|
| 110 |
+
display: flex;
|
| 111 |
+
gap: 8px;
|
| 112 |
+
margin: 32px 0 0;
|
| 113 |
+
|
| 114 |
+
.logo {
|
| 115 |
+
height: 18px;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
a {
|
| 119 |
+
color: var(--text-h);
|
| 120 |
+
font-size: 16px;
|
| 121 |
+
border-radius: 6px;
|
| 122 |
+
background: var(--social-bg);
|
| 123 |
+
display: flex;
|
| 124 |
+
padding: 6px 12px;
|
| 125 |
+
align-items: center;
|
| 126 |
+
gap: 8px;
|
| 127 |
+
text-decoration: none;
|
| 128 |
+
transition: box-shadow 0.3s;
|
| 129 |
+
|
| 130 |
+
&:hover {
|
| 131 |
+
box-shadow: var(--shadow);
|
| 132 |
+
}
|
| 133 |
+
.button-icon {
|
| 134 |
+
height: 18px;
|
| 135 |
+
width: 18px;
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
@media (max-width: 1024px) {
|
| 140 |
+
margin-top: 20px;
|
| 141 |
+
flex-wrap: wrap;
|
| 142 |
+
justify-content: center;
|
| 143 |
+
|
| 144 |
+
li {
|
| 145 |
+
flex: 1 1 calc(50% - 8px);
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
a {
|
| 149 |
+
width: 100%;
|
| 150 |
+
justify-content: center;
|
| 151 |
+
box-sizing: border-box;
|
| 152 |
+
}
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
#spacer {
|
| 157 |
+
height: 88px;
|
| 158 |
+
border-top: 1px solid var(--border);
|
| 159 |
+
@media (max-width: 1024px) {
|
| 160 |
+
height: 48px;
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
.ticks {
|
| 165 |
+
position: relative;
|
| 166 |
+
width: 100%;
|
| 167 |
+
|
| 168 |
+
&::before,
|
| 169 |
+
&::after {
|
| 170 |
+
content: '';
|
| 171 |
+
position: absolute;
|
| 172 |
+
top: -4.5px;
|
| 173 |
+
border: 5px solid transparent;
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
&::before {
|
| 177 |
+
left: 0;
|
| 178 |
+
border-left-color: var(--border);
|
| 179 |
+
}
|
| 180 |
+
&::after {
|
| 181 |
+
right: 0;
|
| 182 |
+
border-right-color: var(--border);
|
| 183 |
+
}
|
| 184 |
+
}
|
frontend/src/App.jsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { BrowserRouter as Router, Routes, Route } from "react-router-dom"
|
| 2 |
+
|
| 3 |
+
import Layout from "./components/layout/Layout"
|
| 4 |
+
import UploadPage from "./pages/UploadPage"
|
| 5 |
+
import ResultPage from "./pages/ResultPage"
|
| 6 |
+
import DashboardPage from "./pages/DashboardPage"
|
| 7 |
+
import HistoryPage from "./pages/HistoryPage"
|
| 8 |
+
|
| 9 |
+
function App() {
|
| 10 |
+
return (
|
| 11 |
+
<Router>
|
| 12 |
+
<Routes>
|
| 13 |
+
<Route path="/" element={<Layout />}>
|
| 14 |
+
<Route index element={<UploadPage />} />
|
| 15 |
+
<Route path="result/:id" element={<ResultPage />} />
|
| 16 |
+
<Route path="dashboard" element={<DashboardPage />} />
|
| 17 |
+
<Route path="history" element={<HistoryPage />} />
|
| 18 |
+
</Route>
|
| 19 |
+
</Routes>
|
| 20 |
+
</Router>
|
| 21 |
+
)
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export default App
|
frontend/src/assets/hero.png
ADDED
|
frontend/src/assets/react.svg
ADDED
|
|
frontend/src/assets/vite.svg
ADDED
|
|
frontend/src/components/layout/Layout.jsx
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react"
|
| 2 |
+
import { Outlet } from "react-router-dom"
|
| 3 |
+
import Sidebar from "./Sidebar"
|
| 4 |
+
import { Bell, Search } from "lucide-react"
|
| 5 |
+
|
| 6 |
+
export default function Layout() {
|
| 7 |
+
return (
|
| 8 |
+
<div className="h-screen flex overflow-hidden bg-slate-50 font-sans">
|
| 9 |
+
<Sidebar />
|
| 10 |
+
|
| 11 |
+
<div className="flex flex-col w-0 flex-1 md:pl-64">
|
| 12 |
+
{/* Top Header */}
|
| 13 |
+
<div className="relative z-10 flex-shrink-0 flex h-16 bg-white shadow-sm border-b border-slate-200">
|
| 14 |
+
<div className="flex-1 px-8 flex justify-between items-center">
|
| 15 |
+
<div className="flex-1 flex">
|
| 16 |
+
<div className="w-full flex md:ml-0">
|
| 17 |
+
{/* Search bar removed per user request */}
|
| 18 |
+
</div>
|
| 19 |
+
</div>
|
| 20 |
+
<div className="ml-4 flex items-center md:ml-6">
|
| 21 |
+
<button className="bg-white p-1 rounded-full text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-at-blue">
|
| 22 |
+
<span className="sr-only">View notifications</span>
|
| 23 |
+
<Bell className="h-6 w-6" />
|
| 24 |
+
</button>
|
| 25 |
+
|
| 26 |
+
<div className="ml-3 flex items-center gap-2">
|
| 27 |
+
<div className="bg-emerald-100 text-emerald-800 text-xs font-bold px-2.5 py-1 rounded-full flex items-center gap-1.5">
|
| 28 |
+
<div className="w-1.5 h-1.5 bg-emerald-500 rounded-full animate-pulse"></div>
|
| 29 |
+
System Online
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
</div>
|
| 35 |
+
|
| 36 |
+
{/* Main Content Area */}
|
| 37 |
+
<main className="flex-1 relative z-0 overflow-y-auto focus:outline-none">
|
| 38 |
+
<div className="py-8 px-8">
|
| 39 |
+
<Outlet />
|
| 40 |
+
</div>
|
| 41 |
+
</main>
|
| 42 |
+
</div>
|
| 43 |
+
</div>
|
| 44 |
+
)
|
| 45 |
+
}
|
frontend/src/components/layout/Sidebar.jsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react"
|
| 2 |
+
import { Link, useLocation } from "react-router-dom"
|
| 3 |
+
import { LayoutDashboard, PhoneCall, History, Mic } from "lucide-react"
|
| 4 |
+
import { cn } from "@/lib/utils"
|
| 5 |
+
|
| 6 |
+
export default function Sidebar() {
|
| 7 |
+
const location = useLocation()
|
| 8 |
+
|
| 9 |
+
const navItems = [
|
| 10 |
+
{ name: "Vue Globale", href: "/dashboard", icon: LayoutDashboard },
|
| 11 |
+
{ name: "Analyse Audio", href: "/", icon: PhoneCall },
|
| 12 |
+
{ name: "Data Explorer", href: "/history", icon: History },
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
return (
|
| 16 |
+
<div className="hidden md:flex md:w-72 md:flex-col md:fixed md:inset-y-0 bg-gradient-to-br from-at-blue via-blue-800 to-slate-900 shadow-2xl z-20">
|
| 17 |
+
<div className="flex-1 flex flex-col min-h-0 relative overflow-hidden">
|
| 18 |
+
|
| 19 |
+
{/* Decorative background blur */}
|
| 20 |
+
<div className="absolute top-0 right-0 -mr-20 -mt-20 w-64 h-64 rounded-full bg-at-orange/10 blur-3xl pointer-events-none"></div>
|
| 21 |
+
<div className="absolute bottom-0 left-0 -ml-20 -mb-20 w-64 h-64 rounded-full bg-blue-500/20 blur-3xl pointer-events-none"></div>
|
| 22 |
+
|
| 23 |
+
<div className="flex items-center h-24 flex-shrink-0 px-8 border-b border-white/5 relative z-10">
|
| 24 |
+
<div className="flex items-center gap-4">
|
| 25 |
+
<div className="h-10 w-10 rounded-xl bg-gradient-to-br from-at-orange to-orange-600 flex items-center justify-center shadow-lg shadow-at-orange/30 animate-pulse-soft">
|
| 26 |
+
<Mic className="h-6 w-6 text-white" />
|
| 27 |
+
</div>
|
| 28 |
+
<div className="flex flex-col">
|
| 29 |
+
<span className="text-white text-2xl font-extrabold tracking-tight font-outfit uppercase">HEAREM</span>
|
| 30 |
+
<span className="text-at-orange text-[9px] font-bold tracking-tight uppercase mt-0.5">Hear the emotion behind the voice</span>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
</div>
|
| 34 |
+
|
| 35 |
+
<div className="flex-1 flex flex-col overflow-y-auto relative z-10 mt-6">
|
| 36 |
+
<nav className="flex-1 px-4 space-y-3">
|
| 37 |
+
<div className="text-[11px] font-bold text-white/40 uppercase tracking-[0.2em] mb-4 px-4">Menu Principal</div>
|
| 38 |
+
{navItems.map((item) => {
|
| 39 |
+
const Icon = item.icon
|
| 40 |
+
const isActive = location.pathname === item.href || (item.href === "/" && location.pathname.startsWith("/result"))
|
| 41 |
+
return (
|
| 42 |
+
<Link
|
| 43 |
+
key={item.name}
|
| 44 |
+
to={item.href}
|
| 45 |
+
className={cn(
|
| 46 |
+
"group flex items-center px-4 py-3.5 text-sm font-semibold rounded-xl transition-all duration-300 relative overflow-hidden",
|
| 47 |
+
isActive
|
| 48 |
+
? "bg-white/10 text-white shadow-[0_0_20px_rgba(255,121,0,0.1)] border border-white/10"
|
| 49 |
+
: "text-white/60 hover:bg-white/5 hover:text-white border border-transparent"
|
| 50 |
+
)}
|
| 51 |
+
>
|
| 52 |
+
{isActive && <div className="absolute left-0 top-0 bottom-0 w-1 bg-at-orange rounded-r-full shadow-[0_0_10px_#FF7900]"></div>}
|
| 53 |
+
<Icon className={cn("mr-4 h-5 w-5 transition-transform duration-300 group-hover:scale-110", isActive ? "text-at-orange drop-shadow-[0_0_8px_rgba(255,121,0,0.5)]" : "text-white/40 group-hover:text-white/70")} />
|
| 54 |
+
{item.name}
|
| 55 |
+
</Link>
|
| 56 |
+
)
|
| 57 |
+
})}
|
| 58 |
+
</nav>
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<div className="flex-shrink-0 flex border-t border-white/5 p-6 relative z-10 bg-black/10 backdrop-blur-md">
|
| 62 |
+
<div className="flex items-center w-full group cursor-pointer">
|
| 63 |
+
<div className="h-10 w-10 rounded-full bg-gradient-to-r from-blue-400 to-at-blue p-[2px]">
|
| 64 |
+
<div className="h-full w-full rounded-full bg-slate-900 flex items-center justify-center">
|
| 65 |
+
<span className="text-white font-bold text-xs">AT</span>
|
| 66 |
+
</div>
|
| 67 |
+
</div>
|
| 68 |
+
<div className="ml-4 flex-1">
|
| 69 |
+
<p className="text-sm font-bold text-white group-hover:text-at-orange transition-colors">Admin Système</p>
|
| 70 |
+
<p className="text-xs font-medium text-white/50">admin@algerietelecom.dz</p>
|
| 71 |
+
</div>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
</div>
|
| 75 |
+
</div>
|
| 76 |
+
)
|
| 77 |
+
}
|
frontend/src/components/ui/badge.jsx
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react"
|
| 2 |
+
import { cn } from "@/lib/utils"
|
| 3 |
+
|
| 4 |
+
const Badge = React.forwardRef(({ className, variant = "default", ...props }, ref) => {
|
| 5 |
+
const variants = {
|
| 6 |
+
default: "border-transparent bg-slate-900 text-slate-50",
|
| 7 |
+
positive: "border-transparent bg-emerald-100 text-emerald-800",
|
| 8 |
+
neutral: "border-transparent bg-yellow-100 text-yellow-800",
|
| 9 |
+
negative: "border-transparent bg-red-100 text-red-800",
|
| 10 |
+
outline: "text-slate-950",
|
| 11 |
+
}
|
| 12 |
+
return (
|
| 13 |
+
<div
|
| 14 |
+
ref={ref}
|
| 15 |
+
className={cn(
|
| 16 |
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2",
|
| 17 |
+
variants[variant],
|
| 18 |
+
className
|
| 19 |
+
)}
|
| 20 |
+
{...props}
|
| 21 |
+
/>
|
| 22 |
+
)
|
| 23 |
+
})
|
| 24 |
+
Badge.displayName = "Badge"
|
| 25 |
+
|
| 26 |
+
export { Badge }
|
frontend/src/components/ui/button.jsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react"
|
| 2 |
+
import { cn } from "@/lib/utils"
|
| 3 |
+
|
| 4 |
+
const Button = React.forwardRef(({ className, variant = "default", size = "default", ...props }, ref) => {
|
| 5 |
+
const variants = {
|
| 6 |
+
default: "bg-slate-900 text-slate-50 hover:bg-slate-900/90",
|
| 7 |
+
outline: "border border-slate-200 bg-white hover:bg-slate-100 hover:text-slate-900",
|
| 8 |
+
ghost: "hover:bg-slate-100 hover:text-slate-900",
|
| 9 |
+
}
|
| 10 |
+
const sizes = {
|
| 11 |
+
default: "h-10 px-4 py-2",
|
| 12 |
+
sm: "h-9 rounded-md px-3",
|
| 13 |
+
lg: "h-11 rounded-md px-8",
|
| 14 |
+
}
|
| 15 |
+
return (
|
| 16 |
+
<button
|
| 17 |
+
ref={ref}
|
| 18 |
+
className={cn(
|
| 19 |
+
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50",
|
| 20 |
+
variants[variant],
|
| 21 |
+
sizes[size],
|
| 22 |
+
className
|
| 23 |
+
)}
|
| 24 |
+
{...props}
|
| 25 |
+
/>
|
| 26 |
+
)
|
| 27 |
+
})
|
| 28 |
+
Button.displayName = "Button"
|
| 29 |
+
|
| 30 |
+
export { Button }
|
frontend/src/components/ui/card.jsx
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react"
|
| 2 |
+
import { cn } from "@/lib/utils"
|
| 3 |
+
|
| 4 |
+
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
| 5 |
+
<div ref={ref} className={cn("rounded-xl border bg-white text-slate-950 shadow-sm", className)} {...props} />
|
| 6 |
+
))
|
| 7 |
+
Card.displayName = "Card"
|
| 8 |
+
|
| 9 |
+
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
| 10 |
+
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
|
| 11 |
+
))
|
| 12 |
+
CardHeader.displayName = "CardHeader"
|
| 13 |
+
|
| 14 |
+
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
| 15 |
+
<h3 ref={ref} className={cn("font-semibold leading-none tracking-tight", className)} {...props} />
|
| 16 |
+
))
|
| 17 |
+
CardTitle.displayName = "CardTitle"
|
| 18 |
+
|
| 19 |
+
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
| 20 |
+
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
| 21 |
+
))
|
| 22 |
+
CardContent.displayName = "CardContent"
|
| 23 |
+
|
| 24 |
+
export { Card, CardHeader, CardTitle, CardContent }
|
frontend/src/components/ui/progress.jsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react"
|
| 2 |
+
import { cn } from "@/lib/utils"
|
| 3 |
+
|
| 4 |
+
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
|
| 5 |
+
<div
|
| 6 |
+
ref={ref}
|
| 7 |
+
className={cn("relative h-4 w-full overflow-hidden rounded-full bg-slate-100", className)}
|
| 8 |
+
{...props}
|
| 9 |
+
>
|
| 10 |
+
<div
|
| 11 |
+
className="h-full w-full flex-1 bg-slate-900 transition-all"
|
| 12 |
+
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
| 13 |
+
/>
|
| 14 |
+
</div>
|
| 15 |
+
))
|
| 16 |
+
Progress.displayName = "Progress"
|
| 17 |
+
|
| 18 |
+
export { Progress }
|
frontend/src/index.css
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=Inter:wght@400;500;600&display=swap');
|
| 2 |
+
|
| 3 |
+
@tailwind base;
|
| 4 |
+
@tailwind components;
|
| 5 |
+
@tailwind utilities;
|
| 6 |
+
|
| 7 |
+
@layer base {
|
| 8 |
+
body {
|
| 9 |
+
/* Subtle premium gradient background */
|
| 10 |
+
@apply bg-slate-50 text-slate-900 font-sans min-h-screen;
|
| 11 |
+
background-image: radial-gradient(circle at top right, rgba(0, 76, 151, 0.03) 0%, transparent 40%),
|
| 12 |
+
radial-gradient(circle at bottom left, rgba(255, 121, 0, 0.03) 0%, transparent 40%);
|
| 13 |
+
background-attachment: fixed;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
::selection {
|
| 17 |
+
background-color: rgba(255, 121, 0, 0.3); /* at-orange/30 */
|
| 18 |
+
color: #004C97; /* at-blue */
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
h1, h2, h3, h4, h5, h6 {
|
| 22 |
+
font-family: 'Outfit', sans-serif;
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
@layer utilities {
|
| 27 |
+
.glass-card {
|
| 28 |
+
@apply bg-white/80 backdrop-blur-md border border-white/40 shadow-[0_8px_30px_rgb(0,0,0,0.04)] transition-all duration-300;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
.glass-card:hover {
|
| 32 |
+
@apply shadow-[0_8px_30px_rgb(0,0,0,0.08)] -translate-y-1 bg-white/90;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
/* Custom Animations */
|
| 37 |
+
@keyframes float {
|
| 38 |
+
0% { transform: translateY(0px); }
|
| 39 |
+
50% { transform: translateY(-10px); }
|
| 40 |
+
100% { transform: translateY(0px); }
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.animate-float {
|
| 44 |
+
animation: float 6s ease-in-out infinite;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
@keyframes pulse-soft {
|
| 48 |
+
0%, 100% { opacity: 1; transform: scale(1); }
|
| 49 |
+
50% { opacity: 0.8; transform: scale(1.02); }
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.animate-pulse-soft {
|
| 53 |
+
animation: pulse-soft 3s ease-in-out infinite;
|
| 54 |
+
}
|
frontend/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.jsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
frontend/src/pages/DashboardPage.jsx
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from "react"
|
| 2 |
+
import axios from "axios"
|
| 3 |
+
import {
|
| 4 |
+
PieChart, Pie, Cell, ResponsiveContainer, Tooltip, Legend,
|
| 5 |
+
LineChart, Line, XAxis, YAxis, CartesianGrid,
|
| 6 |
+
BarChart, Bar
|
| 7 |
+
} from "recharts"
|
| 8 |
+
import { TrendingUp, Users, AlertCircle, PhoneCall } from "lucide-react"
|
| 9 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
| 10 |
+
|
| 11 |
+
export default function DashboardPage() {
|
| 12 |
+
const [history, setHistory] = useState([])
|
| 13 |
+
const [loading, setLoading] = useState(true)
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
const fetchHistory = async () => {
|
| 17 |
+
try {
|
| 18 |
+
const response = await axios.get("/history")
|
| 19 |
+
setHistory(response.data)
|
| 20 |
+
} catch (err) {
|
| 21 |
+
console.error(err)
|
| 22 |
+
} finally {
|
| 23 |
+
setLoading(false)
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
fetchHistory()
|
| 27 |
+
}, [])
|
| 28 |
+
|
| 29 |
+
if (loading) {
|
| 30 |
+
return <div className="flex justify-center items-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-at-blue"></div></div>
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
// Calculate stats
|
| 34 |
+
const totalCalls = history.length
|
| 35 |
+
const positiveCalls = history.filter(h => h.sentiment === "Positive").length
|
| 36 |
+
const neutralCalls = history.filter(h => h.sentiment === "Neutral").length
|
| 37 |
+
const negativeCalls = history.filter(h => h.sentiment === "Negative").length
|
| 38 |
+
|
| 39 |
+
// Satisfaction Score calculation (Mock Formula)
|
| 40 |
+
const satisfactionScore = totalCalls ? Math.round(((positiveCalls * 100) + (neutralCalls * 50)) / totalCalls) : 0
|
| 41 |
+
|
| 42 |
+
// Sentiment Donut Data
|
| 43 |
+
const sentimentData = [
|
| 44 |
+
{ name: 'Positive', value: positiveCalls, color: '#10b981' },
|
| 45 |
+
{ name: 'Neutral', value: neutralCalls, color: '#eab308' },
|
| 46 |
+
{ name: 'Negative', value: negativeCalls, color: '#ef4444' }
|
| 47 |
+
].filter(d => d.value > 0)
|
| 48 |
+
|
| 49 |
+
// Top Issues Processing
|
| 50 |
+
const issueCounts = {}
|
| 51 |
+
history.forEach(call => {
|
| 52 |
+
if (call.keywords) {
|
| 53 |
+
const keys = call.keywords.split(',').map(k => k.trim())
|
| 54 |
+
keys.forEach(k => {
|
| 55 |
+
issueCounts[k] = (issueCounts[k] || 0) + 1
|
| 56 |
+
})
|
| 57 |
+
}
|
| 58 |
+
})
|
| 59 |
+
const topIssues = Object.entries(issueCounts)
|
| 60 |
+
.sort((a, b) => b[1] - a[1])
|
| 61 |
+
.slice(0, 5)
|
| 62 |
+
.map(([name, count]) => ({ name, count }))
|
| 63 |
+
|
| 64 |
+
// Mock Trend Data (Last 7 Days) for Line Chart
|
| 65 |
+
const trendData = [
|
| 66 |
+
{ name: 'Mon', calls: 120, satisfaction: 75 },
|
| 67 |
+
{ name: 'Tue', calls: 132, satisfaction: 72 },
|
| 68 |
+
{ name: 'Wed', calls: 101, satisfaction: 80 },
|
| 69 |
+
{ name: 'Thu', calls: 142, satisfaction: 78 },
|
| 70 |
+
{ name: 'Fri', calls: 90, satisfaction: 85 },
|
| 71 |
+
{ name: 'Sat', calls: 60, satisfaction: 88 },
|
| 72 |
+
{ name: 'Sun', calls: Math.max(totalCalls, 50), satisfaction: satisfactionScore },
|
| 73 |
+
]
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
<div className="max-w-7xl mx-auto">
|
| 77 |
+
<div className="flex items-center justify-between mb-8">
|
| 78 |
+
<div>
|
| 79 |
+
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Intelligence Dashboard</h1>
|
| 80 |
+
<p className="text-slate-500 mt-1">Real-time analysis of Algérie Télécom customer support interactions.</p>
|
| 81 |
+
</div>
|
| 82 |
+
<div className="flex items-center gap-4">
|
| 83 |
+
<div className="bg-white border rounded-lg px-4 py-2 text-sm font-medium text-slate-600 shadow-sm">
|
| 84 |
+
Last 7 Days
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
|
| 89 |
+
{/* KPI Cards */}
|
| 90 |
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
| 91 |
+
<Card className="glass-card border-t-4 border-t-at-blue border-l-0 overflow-hidden relative group">
|
| 92 |
+
<div className="absolute top-0 right-0 -mr-4 -mt-4 w-20 h-20 rounded-full bg-at-blue/5 group-hover:scale-150 transition-transform duration-500 ease-out"></div>
|
| 93 |
+
<CardContent className="p-6 relative z-10">
|
| 94 |
+
<div className="flex justify-between items-start">
|
| 95 |
+
<div>
|
| 96 |
+
<p className="text-sm font-semibold text-slate-500 mb-1">Total Appels Analysés</p>
|
| 97 |
+
<h3 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-br from-slate-900 to-slate-600 font-outfit">{totalCalls}</h3>
|
| 98 |
+
</div>
|
| 99 |
+
<div className="p-3 bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl shadow-inner border border-white">
|
| 100 |
+
<PhoneCall className="h-6 w-6 text-at-blue" />
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
<div className="mt-5 flex items-center text-sm font-semibold">
|
| 104 |
+
<span className="flex items-center text-emerald-500 bg-emerald-50 px-2 py-0.5 rounded-md">
|
| 105 |
+
<TrendingUp className="h-3 w-3 mr-1" />+12.5%
|
| 106 |
+
</span>
|
| 107 |
+
<span className="text-slate-400 ml-2">vs sem. dernière</span>
|
| 108 |
+
</div>
|
| 109 |
+
</CardContent>
|
| 110 |
+
</Card>
|
| 111 |
+
|
| 112 |
+
<Card className="glass-card border-t-4 border-t-emerald-500 border-l-0 overflow-hidden relative group">
|
| 113 |
+
<div className="absolute top-0 right-0 -mr-4 -mt-4 w-20 h-20 rounded-full bg-emerald-500/5 group-hover:scale-150 transition-transform duration-500 ease-out"></div>
|
| 114 |
+
<CardContent className="p-6 relative z-10">
|
| 115 |
+
<div className="flex justify-between items-start">
|
| 116 |
+
<div>
|
| 117 |
+
<p className="text-sm font-semibold text-slate-500 mb-1">Satisfaction Client</p>
|
| 118 |
+
<h3 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-br from-slate-900 to-slate-600 font-outfit">{satisfactionScore}<span className="text-xl text-slate-400">/100</span></h3>
|
| 119 |
+
</div>
|
| 120 |
+
<div className="p-3 bg-gradient-to-br from-emerald-50 to-emerald-100 rounded-xl shadow-inner border border-white">
|
| 121 |
+
<Users className="h-6 w-6 text-emerald-600" />
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
<div className="mt-5 flex items-center text-sm font-semibold">
|
| 125 |
+
<span className="flex items-center text-emerald-500 bg-emerald-50 px-2 py-0.5 rounded-md">
|
| 126 |
+
<TrendingUp className="h-3 w-3 mr-1" />+4.2 pts
|
| 127 |
+
</span>
|
| 128 |
+
<span className="text-slate-400 ml-2">vs sem. dernière</span>
|
| 129 |
+
</div>
|
| 130 |
+
</CardContent>
|
| 131 |
+
</Card>
|
| 132 |
+
|
| 133 |
+
<Card className="glass-card border-t-4 border-t-red-500 border-l-0 overflow-hidden relative group">
|
| 134 |
+
<div className="absolute top-0 right-0 -mr-4 -mt-4 w-20 h-20 rounded-full bg-red-500/5 group-hover:scale-150 transition-transform duration-500 ease-out"></div>
|
| 135 |
+
<CardContent className="p-6 relative z-10">
|
| 136 |
+
<div className="flex justify-between items-start">
|
| 137 |
+
<div>
|
| 138 |
+
<p className="text-sm font-semibold text-slate-500 mb-1">Appels Critiques (Risque)</p>
|
| 139 |
+
<h3 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-br from-red-600 to-red-400 font-outfit">{negativeCalls}</h3>
|
| 140 |
+
</div>
|
| 141 |
+
<div className="p-3 bg-gradient-to-br from-red-50 to-red-100 rounded-xl shadow-inner border border-white">
|
| 142 |
+
<AlertCircle className="h-6 w-6 text-red-600" />
|
| 143 |
+
</div>
|
| 144 |
+
</div>
|
| 145 |
+
<div className="mt-5 flex items-center text-sm font-semibold">
|
| 146 |
+
<span className="flex items-center text-red-500 bg-red-50 px-2 py-0.5 rounded-md">
|
| 147 |
+
<TrendingUp className="h-3 w-3 mr-1" />+2.1%
|
| 148 |
+
</span>
|
| 149 |
+
<span className="text-slate-400 ml-2">alerte active</span>
|
| 150 |
+
</div>
|
| 151 |
+
</CardContent>
|
| 152 |
+
</Card>
|
| 153 |
+
|
| 154 |
+
<Card className="glass-card border-t-4 border-t-at-orange border-l-0 overflow-hidden relative group">
|
| 155 |
+
<div className="absolute top-0 right-0 -mr-4 -mt-4 w-20 h-20 rounded-full bg-at-orange/5 group-hover:scale-150 transition-transform duration-500 ease-out"></div>
|
| 156 |
+
<CardContent className="p-6 relative z-10">
|
| 157 |
+
<div className="flex justify-between items-start">
|
| 158 |
+
<div>
|
| 159 |
+
<p className="text-sm font-semibold text-slate-500 mb-1">Taux Résolution Auto</p>
|
| 160 |
+
<h3 className="text-4xl font-extrabold text-transparent bg-clip-text bg-gradient-to-br from-at-orange to-orange-400 font-outfit">42%</h3>
|
| 161 |
+
</div>
|
| 162 |
+
<div className="p-3 bg-gradient-to-br from-orange-50 to-orange-100 rounded-xl shadow-inner border border-white">
|
| 163 |
+
<TrendingUp className="h-6 w-6 text-at-orange" />
|
| 164 |
+
</div>
|
| 165 |
+
</div>
|
| 166 |
+
<div className="mt-5 flex items-center text-sm font-semibold">
|
| 167 |
+
<span className="text-slate-400 bg-slate-100 px-2 py-0.5 rounded-md">Estimé par IA</span>
|
| 168 |
+
</div>
|
| 169 |
+
</CardContent>
|
| 170 |
+
</Card>
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
| 174 |
+
{/* Trend Chart */}
|
| 175 |
+
<Card className="lg:col-span-2">
|
| 176 |
+
<CardHeader>
|
| 177 |
+
<CardTitle>Call Volume & Satisfaction Trend</CardTitle>
|
| 178 |
+
</CardHeader>
|
| 179 |
+
<CardContent className="h-80">
|
| 180 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 181 |
+
<LineChart data={trendData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
|
| 182 |
+
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#e2e8f0" />
|
| 183 |
+
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{fill: '#64748b'}} dy={10} />
|
| 184 |
+
<YAxis yAxisId="left" axisLine={false} tickLine={false} tick={{fill: '#64748b'}} />
|
| 185 |
+
<YAxis yAxisId="right" orientation="right" domain={[0, 100]} axisLine={false} tickLine={false} tick={{fill: '#64748b'}} />
|
| 186 |
+
<Tooltip
|
| 187 |
+
contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }}
|
| 188 |
+
/>
|
| 189 |
+
<Legend wrapperStyle={{ paddingTop: '20px' }} />
|
| 190 |
+
<Line yAxisId="left" type="monotone" dataKey="calls" stroke="#004C97" strokeWidth={3} activeDot={{ r: 8 }} name="Call Volume" />
|
| 191 |
+
<Line yAxisId="right" type="monotone" dataKey="satisfaction" stroke="#FF7900" strokeWidth={3} name="Satisfaction Score" />
|
| 192 |
+
</LineChart>
|
| 193 |
+
</ResponsiveContainer>
|
| 194 |
+
</CardContent>
|
| 195 |
+
</Card>
|
| 196 |
+
|
| 197 |
+
{/* Sentiment Donut */}
|
| 198 |
+
<Card>
|
| 199 |
+
<CardHeader>
|
| 200 |
+
<CardTitle>Global Sentiment Distribution</CardTitle>
|
| 201 |
+
</CardHeader>
|
| 202 |
+
<CardContent className="h-80 flex flex-col items-center justify-center relative">
|
| 203 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 204 |
+
<PieChart>
|
| 205 |
+
<Pie
|
| 206 |
+
data={sentimentData}
|
| 207 |
+
innerRadius={70}
|
| 208 |
+
outerRadius={100}
|
| 209 |
+
paddingAngle={5}
|
| 210 |
+
dataKey="value"
|
| 211 |
+
>
|
| 212 |
+
{sentimentData.map((entry, index) => (
|
| 213 |
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
| 214 |
+
))}
|
| 215 |
+
</Pie>
|
| 216 |
+
<Tooltip contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} />
|
| 217 |
+
<Legend verticalAlign="bottom" height={36} />
|
| 218 |
+
</PieChart>
|
| 219 |
+
</ResponsiveContainer>
|
| 220 |
+
<div className="absolute inset-0 flex flex-col items-center justify-center pointer-events-none pb-8">
|
| 221 |
+
<span className="text-3xl font-bold text-slate-900">{totalCalls}</span>
|
| 222 |
+
<span className="text-xs text-slate-500 font-medium">Total Calls</span>
|
| 223 |
+
</div>
|
| 224 |
+
</CardContent>
|
| 225 |
+
</Card>
|
| 226 |
+
</div>
|
| 227 |
+
|
| 228 |
+
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
| 229 |
+
{/* Top Issues Bar Chart */}
|
| 230 |
+
<Card>
|
| 231 |
+
<CardHeader>
|
| 232 |
+
<CardTitle>Top Extracted Issues (DziriBERT NLP)</CardTitle>
|
| 233 |
+
</CardHeader>
|
| 234 |
+
<CardContent className="h-80">
|
| 235 |
+
<ResponsiveContainer width="100%" height="100%">
|
| 236 |
+
<BarChart layout="vertical" data={topIssues} margin={{ top: 5, right: 30, left: 60, bottom: 5 }}>
|
| 237 |
+
<CartesianGrid strokeDasharray="3 3" horizontal={false} stroke="#e2e8f0" />
|
| 238 |
+
<XAxis type="number" axisLine={false} tickLine={false} tick={{fill: '#64748b'}} />
|
| 239 |
+
<YAxis dataKey="name" type="category" axisLine={false} tickLine={false} tick={{fill: '#475569', fontSize: 12}} width={120} />
|
| 240 |
+
<Tooltip cursor={{fill: '#f1f5f9'}} contentStyle={{ borderRadius: '8px', border: 'none', boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)' }} />
|
| 241 |
+
<Bar dataKey="count" fill="#FF7900" radius={[0, 4, 4, 0]} barSize={24} name="Mentions" />
|
| 242 |
+
</BarChart>
|
| 243 |
+
</ResponsiveContainer>
|
| 244 |
+
</CardContent>
|
| 245 |
+
</Card>
|
| 246 |
+
|
| 247 |
+
{/* System Status Card */}
|
| 248 |
+
<Card>
|
| 249 |
+
<CardHeader>
|
| 250 |
+
<CardTitle>AI Pipeline Status</CardTitle>
|
| 251 |
+
</CardHeader>
|
| 252 |
+
<CardContent>
|
| 253 |
+
<div className="space-y-6">
|
| 254 |
+
<div className="flex items-center justify-between">
|
| 255 |
+
<div>
|
| 256 |
+
<p className="text-sm font-semibold text-slate-900">Speech-to-Text Engine</p>
|
| 257 |
+
<p className="text-xs text-slate-500">Whisper Medium (Code-Switching Support)</p>
|
| 258 |
+
</div>
|
| 259 |
+
<div className="bg-emerald-100 text-emerald-800 text-xs font-bold px-3 py-1 rounded-full">Operational</div>
|
| 260 |
+
</div>
|
| 261 |
+
<div className="flex items-center justify-between">
|
| 262 |
+
<div>
|
| 263 |
+
<p className="text-sm font-semibold text-slate-900">Sentiment Classifier</p>
|
| 264 |
+
<p className="text-xs text-slate-500">Alger-IA / DziriBERT Sentiment</p>
|
| 265 |
+
</div>
|
| 266 |
+
<div className="bg-emerald-100 text-emerald-800 text-xs font-bold px-3 py-1 rounded-full">Operational</div>
|
| 267 |
+
</div>
|
| 268 |
+
<div className="flex items-center justify-between">
|
| 269 |
+
<div>
|
| 270 |
+
<p className="text-sm font-semibold text-slate-900">Keyword Extractor</p>
|
| 271 |
+
<p className="text-xs text-slate-500">TF-IDF Local Engine</p>
|
| 272 |
+
</div>
|
| 273 |
+
<div className="bg-emerald-100 text-emerald-800 text-xs font-bold px-3 py-1 rounded-full">Operational</div>
|
| 274 |
+
</div>
|
| 275 |
+
<div className="flex items-center justify-between">
|
| 276 |
+
<div>
|
| 277 |
+
<p className="text-sm font-semibold text-slate-900">Database</p>
|
| 278 |
+
<p className="text-xs text-slate-500">SQLite Active Connection</p>
|
| 279 |
+
</div>
|
| 280 |
+
<div className="bg-emerald-100 text-emerald-800 text-xs font-bold px-3 py-1 rounded-full">Connected</div>
|
| 281 |
+
</div>
|
| 282 |
+
</div>
|
| 283 |
+
</CardContent>
|
| 284 |
+
</Card>
|
| 285 |
+
</div>
|
| 286 |
+
|
| 287 |
+
</div>
|
| 288 |
+
)
|
| 289 |
+
}
|
frontend/src/pages/HistoryPage.jsx
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from "react"
|
| 2 |
+
import { Link } from "react-router-dom"
|
| 3 |
+
import axios from "axios"
|
| 4 |
+
import { Search, Filter, PhoneCall, ChevronRight, Calendar, Activity } from "lucide-react"
|
| 5 |
+
|
| 6 |
+
export default function HistoryPage() {
|
| 7 |
+
const [history, setHistory] = useState([])
|
| 8 |
+
const [loading, setLoading] = useState(true)
|
| 9 |
+
|
| 10 |
+
useEffect(() => {
|
| 11 |
+
const fetchHistory = async () => {
|
| 12 |
+
try {
|
| 13 |
+
const response = await axios.get("/history")
|
| 14 |
+
setHistory(response.data)
|
| 15 |
+
} catch (err) {
|
| 16 |
+
console.error(err)
|
| 17 |
+
} finally {
|
| 18 |
+
setLoading(false)
|
| 19 |
+
}
|
| 20 |
+
}
|
| 21 |
+
fetchHistory()
|
| 22 |
+
}, [])
|
| 23 |
+
|
| 24 |
+
const getSentimentColor = (sentiment) => {
|
| 25 |
+
if (sentiment === "Positive") return "bg-emerald-100 text-emerald-800 border-emerald-200"
|
| 26 |
+
if (sentiment === "Negative") return "bg-red-100 text-red-800 border-red-200"
|
| 27 |
+
return "bg-yellow-100 text-yellow-800 border-yellow-200"
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
return (
|
| 31 |
+
<div className="max-w-7xl mx-auto">
|
| 32 |
+
<div className="flex items-center justify-between mb-8">
|
| 33 |
+
<div>
|
| 34 |
+
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Call History</h1>
|
| 35 |
+
<p className="text-slate-500 mt-1">Detailed log of all processed customer interactions.</p>
|
| 36 |
+
</div>
|
| 37 |
+
<div className="flex gap-3">
|
| 38 |
+
<button className="flex items-center gap-2 bg-white border rounded-lg px-4 py-2 text-sm font-medium text-slate-600 shadow-sm hover:bg-slate-50">
|
| 39 |
+
<Filter className="h-4 w-4" /> Filter
|
| 40 |
+
</button>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
<div className="bg-white shadow-sm ring-1 ring-slate-200 rounded-xl overflow-hidden">
|
| 45 |
+
{loading ? (
|
| 46 |
+
<div className="flex justify-center items-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-at-blue"></div></div>
|
| 47 |
+
) : (
|
| 48 |
+
<div className="overflow-x-auto">
|
| 49 |
+
<table className="min-w-full divide-y divide-slate-200">
|
| 50 |
+
<thead className="bg-slate-50">
|
| 51 |
+
<tr>
|
| 52 |
+
<th scope="col" className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 53 |
+
Call ID / File
|
| 54 |
+
</th>
|
| 55 |
+
<th scope="col" className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 56 |
+
Date & Time
|
| 57 |
+
</th>
|
| 58 |
+
<th scope="col" className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 59 |
+
Agent
|
| 60 |
+
</th>
|
| 61 |
+
<th scope="col" className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 62 |
+
Sentiment
|
| 63 |
+
</th>
|
| 64 |
+
<th scope="col" className="px-6 py-4 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">
|
| 65 |
+
Extracted Topics
|
| 66 |
+
</th>
|
| 67 |
+
<th scope="col" className="relative px-6 py-4">
|
| 68 |
+
<span className="sr-only">View</span>
|
| 69 |
+
</th>
|
| 70 |
+
</tr>
|
| 71 |
+
</thead>
|
| 72 |
+
<tbody className="bg-white divide-y divide-slate-200">
|
| 73 |
+
{history.map((call) => (
|
| 74 |
+
<tr key={call.id} className="hover:bg-slate-50 transition-colors group">
|
| 75 |
+
<td className="px-6 py-4 whitespace-nowrap">
|
| 76 |
+
<div className="flex items-center">
|
| 77 |
+
<div className="flex-shrink-0 h-10 w-10 rounded-full bg-slate-100 flex items-center justify-center border border-slate-200">
|
| 78 |
+
<PhoneCall className="h-4 w-4 text-slate-500" />
|
| 79 |
+
</div>
|
| 80 |
+
<div className="ml-4">
|
| 81 |
+
<div className="text-sm font-medium text-slate-900 group-hover:text-at-blue transition-colors">
|
| 82 |
+
{call.filename}
|
| 83 |
+
</div>
|
| 84 |
+
<div className="text-xs text-slate-500">ID: AT-{10000 + call.id}</div>
|
| 85 |
+
</div>
|
| 86 |
+
</div>
|
| 87 |
+
</td>
|
| 88 |
+
<td className="px-6 py-4 whitespace-nowrap">
|
| 89 |
+
<div className="flex items-center text-sm text-slate-900">
|
| 90 |
+
<Calendar className="mr-1.5 h-4 w-4 text-slate-400" />
|
| 91 |
+
{new Date(call.created_at).toLocaleDateString()}
|
| 92 |
+
</div>
|
| 93 |
+
<div className="text-xs text-slate-500 mt-1">
|
| 94 |
+
{new Date(call.created_at).toLocaleTimeString()}
|
| 95 |
+
</div>
|
| 96 |
+
</td>
|
| 97 |
+
<td className="px-6 py-4 whitespace-nowrap">
|
| 98 |
+
<div className="text-sm font-bold text-slate-700">{call.agent_name || "Inconnu"}</div>
|
| 99 |
+
</td>
|
| 100 |
+
<td className="px-6 py-4 whitespace-nowrap">
|
| 101 |
+
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-xs font-semibold border ${getSentimentColor(call.sentiment)}`}>
|
| 102 |
+
{call.sentiment}
|
| 103 |
+
</span>
|
| 104 |
+
<div className="text-xs text-slate-500 mt-1 flex items-center">
|
| 105 |
+
<Activity className="h-3 w-3 mr-1 text-slate-400" />
|
| 106 |
+
{(call.confidence * 100).toFixed(0)}% Conf.
|
| 107 |
+
</div>
|
| 108 |
+
</td>
|
| 109 |
+
<td className="px-6 py-4">
|
| 110 |
+
<div className="flex flex-wrap gap-1">
|
| 111 |
+
{call.keywords ? call.keywords.split(',').slice(0, 2).map((k, i) => (
|
| 112 |
+
<span key={i} className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-100 text-slate-700">
|
| 113 |
+
{k.trim()}
|
| 114 |
+
</span>
|
| 115 |
+
)) : <span className="text-xs text-slate-400">None</span>}
|
| 116 |
+
{call.keywords && call.keywords.split(',').length > 2 && (
|
| 117 |
+
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-slate-50 text-slate-500 border border-slate-200">
|
| 118 |
+
+{call.keywords.split(',').length - 2}
|
| 119 |
+
</span>
|
| 120 |
+
)}
|
| 121 |
+
</div>
|
| 122 |
+
</td>
|
| 123 |
+
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
| 124 |
+
<Link to={`/result/${call.id}`} className="inline-flex items-center text-at-blue hover:text-blue-900 font-semibold bg-blue-50 px-3 py-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity">
|
| 125 |
+
View Details <ChevronRight className="ml-1 h-4 w-4" />
|
| 126 |
+
</Link>
|
| 127 |
+
</td>
|
| 128 |
+
</tr>
|
| 129 |
+
))}
|
| 130 |
+
</tbody>
|
| 131 |
+
</table>
|
| 132 |
+
</div>
|
| 133 |
+
)}
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
)
|
| 137 |
+
}
|
frontend/src/pages/ResultPage.jsx
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useEffect, useState } from "react"
|
| 2 |
+
import { useParams, Link } from "react-router-dom"
|
| 3 |
+
import axios from "axios"
|
| 4 |
+
import { ArrowLeft, MessageSquare, Tags, BarChart2, Calendar, AlertTriangle, ShieldCheck, UserMinus, Clock } from "lucide-react"
|
| 5 |
+
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
| 6 |
+
import { Progress } from "@/components/ui/progress"
|
| 7 |
+
|
| 8 |
+
export default function ResultPage() {
|
| 9 |
+
const { id } = useParams()
|
| 10 |
+
const [data, setData] = useState(null)
|
| 11 |
+
const [loading, setLoading] = useState(true)
|
| 12 |
+
const [error, setError] = useState(null)
|
| 13 |
+
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
const fetchResult = async () => {
|
| 16 |
+
try {
|
| 17 |
+
const response = await axios.get(`/call/${id}`)
|
| 18 |
+
setData(response.data)
|
| 19 |
+
} catch (err) {
|
| 20 |
+
console.error(err)
|
| 21 |
+
setError("Impossible de charger les détails de l'appel.")
|
| 22 |
+
} finally {
|
| 23 |
+
setLoading(false)
|
| 24 |
+
}
|
| 25 |
+
}
|
| 26 |
+
fetchResult()
|
| 27 |
+
}, [id])
|
| 28 |
+
|
| 29 |
+
if (loading) {
|
| 30 |
+
return <div className="flex justify-center items-center h-64"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-at-blue"></div></div>
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
if (error || !data) {
|
| 34 |
+
return <div className="text-center text-red-500 mt-10">{error || "Introuvable"}</div>
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const getSentimentStyle = (sentiment) => {
|
| 38 |
+
if (sentiment === "Positive") return "bg-emerald-100 text-emerald-800 border-emerald-200"
|
| 39 |
+
if (sentiment === "Negative") return "bg-red-100 text-red-800 border-red-200"
|
| 40 |
+
return "bg-yellow-100 text-yellow-800 border-yellow-200"
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
const formatDate = (dateStr) => {
|
| 44 |
+
return new Date(dateStr).toLocaleString('fr-DZ')
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// Churn Risk Logic
|
| 48 |
+
let churnRisk = "Faible"
|
| 49 |
+
let churnColor = "text-emerald-600 bg-emerald-50 border-emerald-200"
|
| 50 |
+
let churnIcon = ShieldCheck
|
| 51 |
+
|
| 52 |
+
if (data.sentiment === "Negative") {
|
| 53 |
+
if (data.confidence > 0.9) {
|
| 54 |
+
churnRisk = "Critique"
|
| 55 |
+
churnColor = "text-red-700 bg-red-50 border-red-200"
|
| 56 |
+
churnIcon = AlertTriangle
|
| 57 |
+
} else {
|
| 58 |
+
churnRisk = "Modéré"
|
| 59 |
+
churnColor = "text-orange-600 bg-orange-50 border-orange-200"
|
| 60 |
+
churnIcon = UserMinus
|
| 61 |
+
}
|
| 62 |
+
} else if (data.sentiment === "Neutral") {
|
| 63 |
+
churnRisk = "Modéré"
|
| 64 |
+
churnColor = "text-yellow-700 bg-yellow-50 border-yellow-200"
|
| 65 |
+
churnIcon = UserMinus
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const RiskIcon = churnIcon
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<div className="max-w-6xl mx-auto">
|
| 72 |
+
<div className="mb-6">
|
| 73 |
+
<Link to="/history" className="inline-flex items-center text-sm font-semibold text-slate-500 hover:text-at-blue transition-colors bg-white px-4 py-2 rounded-lg border border-slate-200 shadow-sm">
|
| 74 |
+
<ArrowLeft className="mr-2 h-4 w-4" />
|
| 75 |
+
Retour à l'Historique
|
| 76 |
+
</Link>
|
| 77 |
+
</div>
|
| 78 |
+
|
| 79 |
+
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-8 mb-8">
|
| 80 |
+
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-6">
|
| 81 |
+
<div>
|
| 82 |
+
<div className="flex items-center gap-3 mb-2">
|
| 83 |
+
<span className="text-xs font-bold text-slate-400 uppercase tracking-widest">ID Appel: AT-{10000 + data.id}</span>
|
| 84 |
+
<span className={`px-3 py-1 rounded-full text-xs font-bold border uppercase tracking-wider ${getSentimentStyle(data.sentiment)}`}>
|
| 85 |
+
Sentiment {data.sentiment}
|
| 86 |
+
</span>
|
| 87 |
+
</div>
|
| 88 |
+
<h1 className="text-3xl font-bold tracking-tight text-slate-900">{data.filename}</h1>
|
| 89 |
+
<div className="flex items-center text-slate-500 mt-3 text-sm gap-6 font-medium">
|
| 90 |
+
<span className="flex items-center"><Calendar className="mr-2 h-4 w-4 text-slate-400" /> {formatDate(data.created_at)}</span>
|
| 91 |
+
<span className="flex items-center"><UserMinus className="mr-2 h-4 w-4 text-slate-400" /> Agent: {data.agent_name || "Inconnu"}</span>
|
| 92 |
+
<span className="flex items-center"><Clock className="mr-2 h-4 w-4 text-slate-400" /> Durée: Analysée</span>
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div className={`flex items-center gap-4 px-6 py-4 rounded-xl border ${churnColor}`}>
|
| 97 |
+
<div className="p-3 bg-white/60 rounded-full">
|
| 98 |
+
<RiskIcon className="h-8 w-8" />
|
| 99 |
+
</div>
|
| 100 |
+
<div>
|
| 101 |
+
<p className="text-sm font-bold opacity-80 uppercase tracking-wider">Risque d'attrition (Churn)</p>
|
| 102 |
+
<p className="text-2xl font-black">{churnRisk}</p>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 109 |
+
<div className="lg:col-span-2 space-y-8">
|
| 110 |
+
<Card className="shadow-sm border-slate-200 overflow-hidden">
|
| 111 |
+
<div className="bg-slate-50 border-b border-slate-100 px-6 py-4 flex items-center justify-between">
|
| 112 |
+
<h3 className="text-lg font-bold text-slate-900 flex items-center">
|
| 113 |
+
<MessageSquare className="mr-2 h-5 w-5 text-at-blue" />
|
| 114 |
+
Transcription Audio (DziriBERT / Whisper)
|
| 115 |
+
</h3>
|
| 116 |
+
</div>
|
| 117 |
+
<CardContent className="p-6">
|
| 118 |
+
<div className="prose prose-slate max-w-none">
|
| 119 |
+
<p className="text-slate-800 text-lg leading-relaxed whitespace-pre-wrap font-medium">
|
| 120 |
+
{data.transcript || "Aucune transcription disponible."}
|
| 121 |
+
</p>
|
| 122 |
+
</div>
|
| 123 |
+
</CardContent>
|
| 124 |
+
</Card>
|
| 125 |
+
</div>
|
| 126 |
+
|
| 127 |
+
<div className="space-y-8">
|
| 128 |
+
<Card className="shadow-sm border-slate-200">
|
| 129 |
+
<div className="bg-slate-50 border-b border-slate-100 px-6 py-4">
|
| 130 |
+
<h3 className="text-lg font-bold text-slate-900 flex items-center">
|
| 131 |
+
<BarChart2 className="mr-2 h-5 w-5 text-at-blue" />
|
| 132 |
+
Fiabilité IA
|
| 133 |
+
</h3>
|
| 134 |
+
</div>
|
| 135 |
+
<CardContent className="p-6">
|
| 136 |
+
<div className="mb-2 flex justify-between items-end">
|
| 137 |
+
<span className="text-4xl font-black text-slate-900">{(data.confidence * 100).toFixed(1)}%</span>
|
| 138 |
+
<span className="text-sm text-slate-500 font-bold uppercase tracking-wider">Score de Confiance</span>
|
| 139 |
+
</div>
|
| 140 |
+
<Progress value={data.confidence * 100} className="h-3 mt-4" />
|
| 141 |
+
<p className="text-xs text-slate-400 mt-3 font-medium">
|
| 142 |
+
La probabilité que le modèle DziriBERT ait correctement identifié le sentiment de cet appel.
|
| 143 |
+
</p>
|
| 144 |
+
</CardContent>
|
| 145 |
+
</Card>
|
| 146 |
+
|
| 147 |
+
<Card className="shadow-sm border-slate-200">
|
| 148 |
+
<div className="bg-slate-50 border-b border-slate-100 px-6 py-4">
|
| 149 |
+
<h3 className="text-lg font-bold text-slate-900 flex items-center">
|
| 150 |
+
<Tags className="mr-2 h-5 w-5 text-at-blue" />
|
| 151 |
+
Mots-Clés Extraits
|
| 152 |
+
</h3>
|
| 153 |
+
</div>
|
| 154 |
+
<CardContent className="p-6">
|
| 155 |
+
<div className="flex flex-wrap gap-2">
|
| 156 |
+
{data.keywords ? (
|
| 157 |
+
data.keywords.split(',').map((keyword, idx) => (
|
| 158 |
+
<span key={idx} className="px-3 py-1.5 bg-blue-50 text-blue-700 border border-blue-100 font-bold text-sm rounded-lg shadow-sm">
|
| 159 |
+
{keyword.trim()}
|
| 160 |
+
</span>
|
| 161 |
+
))
|
| 162 |
+
) : (
|
| 163 |
+
<span className="text-sm text-slate-500 italic">Aucun mot-clé pertinent extrait.</span>
|
| 164 |
+
)}
|
| 165 |
+
</div>
|
| 166 |
+
</CardContent>
|
| 167 |
+
</Card>
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
</div>
|
| 171 |
+
)
|
| 172 |
+
}
|
frontend/src/pages/UploadPage.jsx
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useRef } from "react"
|
| 2 |
+
import { useNavigate } from "react-router-dom"
|
| 3 |
+
import axios from "axios"
|
| 4 |
+
import { UploadCloud, FileAudio, Loader2, Server, ShieldCheck } from "lucide-react"
|
| 5 |
+
|
| 6 |
+
export default function UploadPage() {
|
| 7 |
+
const [file, setFile] = useState(null)
|
| 8 |
+
const [agentName, setAgentName] = useState("")
|
| 9 |
+
const [isDragging, setIsDragging] = useState(false)
|
| 10 |
+
const [isUploading, setIsUploading] = useState(false)
|
| 11 |
+
const [error, setError] = useState(null)
|
| 12 |
+
|
| 13 |
+
const fileInputRef = useRef(null)
|
| 14 |
+
const navigate = useNavigate()
|
| 15 |
+
|
| 16 |
+
const handleDragOver = (e) => {
|
| 17 |
+
e.preventDefault()
|
| 18 |
+
setIsDragging(true)
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const handleDragLeave = () => {
|
| 22 |
+
setIsDragging(false)
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
const handleDrop = (e) => {
|
| 26 |
+
e.preventDefault()
|
| 27 |
+
setIsDragging(false)
|
| 28 |
+
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
| 29 |
+
const droppedFile = e.dataTransfer.files[0]
|
| 30 |
+
validateAndSetFile(droppedFile)
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const handleFileChange = (e) => {
|
| 35 |
+
if (e.target.files && e.target.files.length > 0) {
|
| 36 |
+
validateAndSetFile(e.target.files[0])
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const validateAndSetFile = (selectedFile) => {
|
| 41 |
+
const validTypes = ['audio/wav', 'audio/mpeg', 'audio/ogg', 'audio/x-m4a']
|
| 42 |
+
if (validTypes.includes(selectedFile.type) || selectedFile.name.match(/\.(wav|mp3|ogg|m4a)$/i)) {
|
| 43 |
+
setFile(selectedFile)
|
| 44 |
+
setError(null)
|
| 45 |
+
} else {
|
| 46 |
+
setError("Format non supporté. Veuillez utiliser .wav, .mp3, .ogg ou .m4a")
|
| 47 |
+
setFile(null)
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
const handleUpload = async () => {
|
| 52 |
+
if (!file) return
|
| 53 |
+
setIsUploading(true)
|
| 54 |
+
setError(null)
|
| 55 |
+
|
| 56 |
+
const formData = new FormData()
|
| 57 |
+
formData.append("file", file)
|
| 58 |
+
formData.append("agent_name", agentName || "user")
|
| 59 |
+
|
| 60 |
+
try {
|
| 61 |
+
const response = await axios.post("/analyze-call", formData, {
|
| 62 |
+
headers: { "Content-Type": "multipart/form-data" }
|
| 63 |
+
})
|
| 64 |
+
if (response.data.success) {
|
| 65 |
+
navigate(`/result/${response.data.data.id}`)
|
| 66 |
+
}
|
| 67 |
+
} catch (err) {
|
| 68 |
+
console.error(err)
|
| 69 |
+
setError(err.response?.data?.detail || "Erreur de connexion au serveur IA.")
|
| 70 |
+
} finally {
|
| 71 |
+
setIsUploading(false)
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
<div className="max-w-4xl mx-auto h-full flex flex-col justify-center pb-20 relative">
|
| 77 |
+
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-gradient-to-br from-at-blue/5 to-at-orange/5 rounded-full blur-3xl -z-10 pointer-events-none animate-pulse-soft"></div>
|
| 78 |
+
|
| 79 |
+
<div className="mb-10 text-center relative z-10">
|
| 80 |
+
<h1 className="text-5xl font-black tracking-tight text-transparent bg-clip-text bg-gradient-to-r from-slate-900 to-slate-700 mb-4 font-outfit drop-shadow-sm">Module d'Ingestion Audio</h1>
|
| 81 |
+
<p className="text-lg text-slate-500 max-w-2xl mx-auto font-medium">
|
| 82 |
+
Déposez un enregistrement d'appel client (Dialecte Algérien / Français) pour une transcription sécurisée et une analyse sémantique par notre moteur IA.
|
| 83 |
+
</p>
|
| 84 |
+
</div>
|
| 85 |
+
|
| 86 |
+
<div className="glass-card rounded-3xl overflow-hidden relative z-10">
|
| 87 |
+
<div className="flex border-b border-slate-100 bg-white/50 backdrop-blur-xl px-8 py-5 items-center justify-between">
|
| 88 |
+
<div className="flex items-center gap-3 text-sm font-bold text-slate-700 uppercase tracking-widest">
|
| 89 |
+
<Server className="h-5 w-5 text-at-blue" /> Pipeline IA: Connecté
|
| 90 |
+
</div>
|
| 91 |
+
<div className="flex items-center gap-2 text-xs font-bold text-emerald-700 bg-emerald-50 px-3 py-1.5 rounded-full border border-emerald-200 shadow-sm">
|
| 92 |
+
<ShieldCheck className="h-4 w-4" /> Traitement Local Sécurisé
|
| 93 |
+
</div>
|
| 94 |
+
</div>
|
| 95 |
+
|
| 96 |
+
<div className="p-12">
|
| 97 |
+
<div
|
| 98 |
+
className={`relative border-2 border-dashed rounded-3xl p-16 text-center transition-all duration-500 ease-out cursor-pointer overflow-hidden ${
|
| 99 |
+
isDragging
|
| 100 |
+
? "border-at-blue bg-blue-50/80 scale-[1.02] shadow-[0_0_40px_rgba(0,76,151,0.1)]"
|
| 101 |
+
: "border-slate-300 hover:border-at-blue/50 bg-slate-50/50 hover:bg-white"
|
| 102 |
+
}`}
|
| 103 |
+
onDragOver={handleDragOver}
|
| 104 |
+
onDragLeave={handleDragLeave}
|
| 105 |
+
onDrop={handleDrop}
|
| 106 |
+
onClick={() => !isUploading && fileInputRef.current?.click()}
|
| 107 |
+
>
|
| 108 |
+
{isDragging && <div className="absolute inset-0 bg-gradient-to-br from-at-blue/5 to-transparent pointer-events-none"></div>}
|
| 109 |
+
|
| 110 |
+
<input
|
| 111 |
+
type="file"
|
| 112 |
+
className="hidden"
|
| 113 |
+
ref={fileInputRef}
|
| 114 |
+
onChange={handleFileChange}
|
| 115 |
+
accept=".wav,.mp3,.ogg,.m4a"
|
| 116 |
+
/>
|
| 117 |
+
|
| 118 |
+
{!file ? (
|
| 119 |
+
<div className="flex flex-col items-center justify-center relative z-10 animate-float">
|
| 120 |
+
<div className="h-24 w-24 bg-white rounded-2xl flex items-center justify-center shadow-xl shadow-slate-200/50 mb-8 border border-slate-100 group-hover:scale-110 group-hover:rotate-3 transition-all duration-500">
|
| 121 |
+
<UploadCloud className="h-12 w-12 text-at-blue drop-shadow-md" />
|
| 122 |
+
</div>
|
| 123 |
+
<h3 className="text-2xl font-black text-slate-900 mb-3 font-outfit">Cliquez ou glissez un fichier audio ici</h3>
|
| 124 |
+
<p className="text-base text-slate-500 font-medium">Formats supportés: WAV, MP3, OGG, M4A (Max 50MB)</p>
|
| 125 |
+
</div>
|
| 126 |
+
) : (
|
| 127 |
+
<div className="flex flex-col items-center justify-center relative z-10">
|
| 128 |
+
<div className="h-24 w-24 bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-blue-900/10 border border-blue-200 animate-pulse-soft">
|
| 129 |
+
<FileAudio className="h-12 w-12 text-at-blue" />
|
| 130 |
+
</div>
|
| 131 |
+
<h3 className="text-2xl font-black text-slate-900 mb-2 truncate max-w-md font-outfit">{file.name}</h3>
|
| 132 |
+
<p className="text-sm font-bold text-at-blue mb-8 bg-blue-50 px-4 py-1.5 rounded-full border border-blue-100">
|
| 133 |
+
{(file.size / 1024 / 1024).toFixed(2)} MB
|
| 134 |
+
</p>
|
| 135 |
+
<button
|
| 136 |
+
className="text-sm font-bold text-slate-400 hover:text-slate-800 transition-colors uppercase tracking-wider"
|
| 137 |
+
onClick={(e) => { e.stopPropagation(); setFile(null); }}
|
| 138 |
+
disabled={isUploading}
|
| 139 |
+
>
|
| 140 |
+
Changer de fichier
|
| 141 |
+
</button>
|
| 142 |
+
</div>
|
| 143 |
+
)}
|
| 144 |
+
</div>
|
| 145 |
+
|
| 146 |
+
<div className="mt-8 mb-4 max-w-sm mx-auto">
|
| 147 |
+
<label htmlFor="agent_name" className="block text-sm font-bold text-slate-700 mb-2">Traçabilité de l'Agent (Optionnel)</label>
|
| 148 |
+
<input
|
| 149 |
+
type="text"
|
| 150 |
+
id="agent_name"
|
| 151 |
+
placeholder="user"
|
| 152 |
+
value={agentName}
|
| 153 |
+
onChange={(e) => setAgentName(e.target.value)}
|
| 154 |
+
className="w-full px-4 py-3 rounded-xl border border-slate-200 focus:border-at-blue focus:ring-2 focus:ring-at-blue/20 transition-all font-medium text-slate-700 outline-none"
|
| 155 |
+
/>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
+
{error && (
|
| 159 |
+
<div className="mt-6 p-4 bg-red-50 border border-red-100 text-red-700 rounded-xl text-sm flex items-center gap-3 font-semibold shadow-sm animate-pulse">
|
| 160 |
+
<ShieldCheck className="h-5 w-5" /> {error}
|
| 161 |
+
</div>
|
| 162 |
+
)}
|
| 163 |
+
|
| 164 |
+
<div className="mt-10 flex justify-center">
|
| 165 |
+
<button
|
| 166 |
+
onClick={handleUpload}
|
| 167 |
+
disabled={!file || isUploading}
|
| 168 |
+
className={`flex items-center justify-center h-16 px-10 rounded-2xl font-black text-lg text-white transition-all duration-300 font-outfit tracking-wide ${
|
| 169 |
+
!file || isUploading
|
| 170 |
+
? "bg-slate-200 text-slate-400 cursor-not-allowed"
|
| 171 |
+
: "bg-gradient-to-r from-at-blue via-blue-600 to-at-blue hover:scale-105 shadow-[0_10px_40px_rgba(0,76,151,0.3)] hover:shadow-[0_15px_50px_rgba(0,76,151,0.4)] bg-[length:200%_auto] hover:bg-[position:right_center]"
|
| 172 |
+
} w-full sm:w-auto min-w-[300px]`}
|
| 173 |
+
>
|
| 174 |
+
{isUploading ? (
|
| 175 |
+
<>
|
| 176 |
+
<Loader2 className="mr-3 h-6 w-6 animate-spin" />
|
| 177 |
+
Analyse IA en cours...
|
| 178 |
+
</>
|
| 179 |
+
) : (
|
| 180 |
+
"Lancer l'Analyse d'Appel"
|
| 181 |
+
)}
|
| 182 |
+
</button>
|
| 183 |
+
</div>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
)
|
| 188 |
+
}
|
frontend/tailwind.config.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/** @type {import('tailwindcss').Config} */
|
| 2 |
+
export default {
|
| 3 |
+
content: [
|
| 4 |
+
"./index.html",
|
| 5 |
+
"./src/**/*.{js,ts,jsx,tsx}",
|
| 6 |
+
],
|
| 7 |
+
theme: {
|
| 8 |
+
extend: {
|
| 9 |
+
colors: {
|
| 10 |
+
at: {
|
| 11 |
+
blue: '#004C97',
|
| 12 |
+
orange: '#FF7900',
|
| 13 |
+
}
|
| 14 |
+
},
|
| 15 |
+
fontFamily: {
|
| 16 |
+
sans: ['Inter', 'ui-sans-serif', 'system-ui', 'sans-serif'],
|
| 17 |
+
outfit: ['Outfit', 'sans-serif'],
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
},
|
| 21 |
+
plugins: [],
|
| 22 |
+
}
|
frontend/vite.config.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
import path from "path"
|
| 4 |
+
|
| 5 |
+
// https://vitejs.dev/config/
|
| 6 |
+
export default defineConfig({
|
| 7 |
+
plugins: [react()],
|
| 8 |
+
resolve: {
|
| 9 |
+
alias: {
|
| 10 |
+
"@": path.resolve(__dirname, "./src"),
|
| 11 |
+
},
|
| 12 |
+
},
|
| 13 |
+
})
|