feat: initial MAIA monorepo - frontend, backend, diarization API
- PWA React 18 + Vite + TypeScript com gravacao continua e upload de chunks - Backend FastAPI + Celery + PostgreSQL + Redis para processamento assincrono - Diarization API com SpeechBrain ECAPA-TDNN para identificacao de speakers - Integracao com Whisper.cpp GPU (large-v3) para transcricao - Integracao com LLM deepseek-v3.1 para analise de reunioes - Autenticacao SSO via Keycloak - README.md completo com arquitetura, instalacao e configuracao - CHANGELOG.md com historico v1.0.0 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
|
@ -0,0 +1,77 @@
|
|||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
ENV/
|
||||
*.egg-info/
|
||||
dist/
|
||||
build/
|
||||
.eggs/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
htmlcov/
|
||||
.coverage
|
||||
*.log
|
||||
|
||||
# Node.js / Frontend
|
||||
node_modules/
|
||||
dist/
|
||||
.next/
|
||||
out/
|
||||
.nuxt/
|
||||
*.local
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
.env.production
|
||||
*.env
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
desktop.ini
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Docker / Podman
|
||||
*.tar
|
||||
*.tar.gz
|
||||
|
||||
# Data / Models (não versionar modelos grandes)
|
||||
*.bin
|
||||
*.pt
|
||||
*.onnx
|
||||
voice_db/
|
||||
speechbrain_cache/
|
||||
uploads/
|
||||
media/
|
||||
|
||||
# Audio files (storage)
|
||||
storage/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Tests
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
|
||||
# Alembic (manter só as migrations, não o cache)
|
||||
__pycache__/
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
# Changelog
|
||||
|
||||
Todas as mudanças notáveis neste projeto serão documentadas neste arquivo.
|
||||
|
||||
O formato é baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/),
|
||||
e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR/).
|
||||
|
||||
---
|
||||
|
||||
## [1.0.0] - 2026-04-23
|
||||
|
||||
### Adicionado
|
||||
|
||||
#### Frontend (PWA)
|
||||
- PWA (Progressive Web App) instalável com React 18 + Vite + TypeScript
|
||||
- Gravação contínua de áudio com MediaRecorder API (timeslice, sem gaps entre chunks)
|
||||
- Upload automático de chunks de áudio com retry automático em caso de falha
|
||||
- VU meter (visualizador de volume) na tela de gravação
|
||||
- Autenticação via Keycloak SSO com refresh automático de token
|
||||
- Tela de histórico de reuniões com busca e filtros
|
||||
- Tela de detalhes da reunião: transcrição, análise e speakers
|
||||
- Administração: gerenciamento de usuários, roles e delete de reuniões
|
||||
- Enrollment de voz para identificação automática de speakers
|
||||
- Branding MAIA: logo, cores e tipografia ASAP Telecom
|
||||
- PWA instalável em Android (Chrome) e iOS (Safari)
|
||||
- Suporte offline básico via Service Worker (Workbox)
|
||||
|
||||
#### Backend API
|
||||
- API REST com FastAPI + SQLAlchemy + Alembic
|
||||
- PostgreSQL como banco de dados relacional
|
||||
- Celery + Redis para processamento assíncrono de transcrições e análises
|
||||
- Recepção e armazenamento de chunks de áudio
|
||||
- Concatenação automática de chunks ao finalizar reunião
|
||||
- Integração com Whisper GPU (large-v3) para transcrição
|
||||
- Integração com Diarization API para identificação de speakers
|
||||
- Integração com LLM (deepseek-v3.1) para análise e resumo
|
||||
- Autenticação JWT via Keycloak com verificação de roles
|
||||
- Endpoint de administração com controle de acesso por role
|
||||
- Título automático de reunião: "Nome Usuário DD/MM/YYYY, HH:MM"
|
||||
- Gestão de speakers: cadastro, enrollment e listagem
|
||||
- API de busca no histórico de reuniões
|
||||
|
||||
#### Diarization API
|
||||
- API FastAPI dedicada para transcrição + diarização
|
||||
- Integração com Whisper.cpp via HTTP (GPU AMD, servidor dedicado)
|
||||
- Diarização de speakers com SpeechBrain ECAPA-TDNN (~85% precisão)
|
||||
- Fallback para pyannote.audio quando necessário
|
||||
- Banco de vozes para identificação automática de speakers conhecidos
|
||||
- Enrollment de novas vozes via API
|
||||
- Processamento de áudio com librosa (normalização, VAD)
|
||||
- Speaker confidence threshold configurável
|
||||
|
||||
#### Infraestrutura
|
||||
- Containerização com Podman
|
||||
- Nginx para servir o frontend e reverse proxy
|
||||
- Serviços gerenciados via systemd
|
||||
- GPU Server dedicado (10.100.16.13) para Whisper large-v3
|
||||
- LLM Server com Ollama (deepseek-v3.1:671b-cloud)
|
||||
- Keycloak para SSO centralizado
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Planejado
|
||||
- Exportação de reuniões em PDF e DOCX
|
||||
- Integração com Google Calendar e Outlook
|
||||
- Resumos automáticos por e-mail
|
||||
- Suporte a múltiplos idiomas na transcrição
|
||||
- Dashboard de analytics de reuniões
|
||||
- API pública documentada com Swagger
|
||||
|
||||
---
|
||||
|
||||
[1.0.0]: https://github.com/ASAPTelecom/Maia/releases/tag/v1.0.0
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
# MAIA — Meeting AI Assistant
|
||||
|
||||
<p align="center">
|
||||
<img src="frontend/public/maia-logo.png" alt="MAIA Logo" width="120"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Grave, transcreva e analise suas reuniões com Inteligência Artificial</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#-instalação">Instalação</a> •
|
||||
<a href="#-arquitetura">Arquitetura</a> •
|
||||
<a href="#-configuração">Configuração</a> •
|
||||
<a href="#-deploy">Deploy</a> •
|
||||
<a href="#-endpoints-da-api">API</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 📖 Sobre o Projeto
|
||||
|
||||
O **MAIA** (Meeting AI Assistant) é uma plataforma desenvolvida pela **ASAP Telecom** para gravar, transcrever e analisar reuniões automaticamente usando Inteligência Artificial.
|
||||
|
||||
### ✨ Funcionalidades Principais
|
||||
|
||||
- 🎙️ **Gravação contínua** de reuniões via navegador (sem gaps, usando MediaRecorder API com timeslice)
|
||||
- 📝 **Transcrição automática** com Whisper.cpp na GPU (modelo large-v3, alta precisão)
|
||||
- 👥 **Diarização de speakers** — identifica quem falou o quê (SpeechBrain ECAPA-TDNN, ~85% precisão)
|
||||
- 🤖 **Análise com IA** — resumo, pontos de ação e insights com LLM (deepseek-v3.1)
|
||||
- 🔐 **Autenticação SSO** via Keycloak
|
||||
- 📱 **PWA instalável** em Android e iOS
|
||||
- 🔎 **Histórico** com busca de reuniões passadas
|
||||
- 👤 **Identificação de speakers** por enrollment de voz
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Arquitetura
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ CLIENTE │
|
||||
│ Browser / PWA (React 18 + Vite + TypeScript) │
|
||||
│ MediaRecorder API → chunks de áudio → upload automático │
|
||||
└────────────────────────┬────────────────────────────────────────┘
|
||||
│ HTTPS
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ APP SERVER (5.0.0.181) │
|
||||
│ ┌─────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Nginx :80 │ │ Backend API :8090 │ │ Diarization :5060│ │
|
||||
│ │ (Frontend) │ │ FastAPI + Celery │ │ FastAPI+SpeechBr.│ │
|
||||
│ └─────────────┘ └────────┬─────────┘ └────────┬─────────┘ │
|
||||
│ │ │ │
|
||||
│ ┌─────────────┐ ┌────────▼─────────┐ │ │
|
||||
│ │ PostgreSQL │ │ Redis (Celery) │ │ │
|
||||
│ └─────────────┘ └──────────────────┘ │ │
|
||||
└────────────────────────────┬──────────────────────┼────────────┘
|
||||
│ │
|
||||
┌──────────────▼──────┐ ┌──────────▼───────────┐
|
||||
│ GPU SERVER │ │ LLM Server │
|
||||
│ 10.100.16.13 :5003 │ │ asapai.asaptelecom. │
|
||||
│ Whisper.cpp GPU │ │ com.br/ollama │
|
||||
│ AMD GPU, large-v3 │ │ deepseek-v3.1:671b │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
|
||||
┌─────────────────────┐
|
||||
│ Keycloak SSO │
|
||||
│ Autenticação/Authz │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
### 📁 Estrutura do Monorepo
|
||||
|
||||
```
|
||||
Maia/
|
||||
├── frontend/ # PWA React 18 + Vite + TypeScript
|
||||
├── backend/ # API FastAPI + Celery + PostgreSQL
|
||||
├── diarization/ # API de Diarização com SpeechBrain
|
||||
├── README.md
|
||||
└── CHANGELOG.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Requisitos
|
||||
|
||||
| Componente | Requisito |
|
||||
|------------|-----------|
|
||||
| Node.js | 18+ |
|
||||
| Python | 3.11+ |
|
||||
| PostgreSQL | 14+ |
|
||||
| Redis | 7+ |
|
||||
| GPU | AMD/NVIDIA (para Whisper GPU) |
|
||||
| RAM | 16 GB mínimo (32 GB recomendado) |
|
||||
| VRAM | 8 GB mínimo para Whisper large-v3 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Instalação
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# Edite o .env com as variáveis necessárias
|
||||
npm run dev # desenvolvimento
|
||||
npm run build # produção
|
||||
```
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate # Linux/Mac
|
||||
venv\Scripts\activate # Windows
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
# Edite o .env com as variáveis necessárias
|
||||
alembic upgrade head # aplicar migrações
|
||||
uvicorn app.main:app --host 0.0.0.0 --port 8090
|
||||
```
|
||||
|
||||
### Diarization API
|
||||
|
||||
```bash
|
||||
cd diarization
|
||||
python -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
uvicorn main:app --host 0.0.0.0 --port 5060
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuração
|
||||
|
||||
### Variáveis de Ambiente — Backend (`backend/.env`)
|
||||
|
||||
```env
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/maia
|
||||
REDIS_URL=redis://localhost:6379/0
|
||||
KEYCLOAK_URL=https://seu-keycloak.exemplo.com
|
||||
KEYCLOAK_REALM=seu-realm
|
||||
KEYCLOAK_CLIENT_ID=maia-backend
|
||||
OLLAMA_URL=https://llm.exemplo.com/ollama
|
||||
OLLAMA_MODEL=deepseek-v3.1:671b-cloud
|
||||
WHISPER_URL=http://gpu-server:5003
|
||||
```
|
||||
|
||||
### Variáveis de Ambiente — Diarization API (`diarization/.env`)
|
||||
|
||||
```env
|
||||
WHISPER_ORIGIN=http://gpu-server:5003
|
||||
VOICE_DB_PATH=/data/voice_db
|
||||
SPEAKER_CONFIDENCE_THRESHOLD=0.75
|
||||
HF_TOKEN=seu_token_huggingface
|
||||
SPEECHBRAIN_CACHE=/data/speechbrain_cache
|
||||
```
|
||||
|
||||
### Variáveis de Ambiente — Frontend (`frontend/.env`)
|
||||
|
||||
```env
|
||||
VITE_API_URL=http://seu-backend:8090
|
||||
VITE_KEYCLOAK_URL=https://seu-keycloak.exemplo.com
|
||||
VITE_KEYCLOAK_REALM=seu-realm
|
||||
VITE_KEYCLOAK_CLIENT_ID=maia-frontend
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Deploy
|
||||
|
||||
### Com Podman/Docker Compose
|
||||
|
||||
```bash
|
||||
# Na raiz do projeto
|
||||
podman-compose up -d
|
||||
```
|
||||
|
||||
### Deploy Manual
|
||||
|
||||
#### Backend (systemd)
|
||||
```bash
|
||||
# /etc/systemd/system/maia-backend.service
|
||||
[Unit]
|
||||
Description=MAIA Backend API
|
||||
After=network.target postgresql.service redis.service
|
||||
|
||||
[Service]
|
||||
User=maia
|
||||
WorkingDirectory=/opt/Backend/meeting-assistant-api
|
||||
ExecStart=/opt/Backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8090
|
||||
Restart=always
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
#### Celery Worker
|
||||
```bash
|
||||
celery -A app.celery_app worker --loglevel=info -Q transcription,analysis
|
||||
```
|
||||
|
||||
#### Frontend (Nginx)
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
root /var/www/maia;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://localhost:8090/;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔌 Endpoints Principais da API
|
||||
|
||||
### Backend API (`http://server:8090`)
|
||||
|
||||
| Método | Endpoint | Descrição |
|
||||
|--------|----------|-----------|
|
||||
| `POST` | `/meetings/` | Criar nova reunião |
|
||||
| `GET` | `/meetings/` | Listar reuniões |
|
||||
| `GET` | `/meetings/{id}` | Detalhes da reunião |
|
||||
| `DELETE` | `/meetings/{id}` | Deletar reunião |
|
||||
| `POST` | `/meetings/{id}/upload-chunk` | Upload de chunk de áudio |
|
||||
| `POST` | `/meetings/{id}/finalize` | Finalizar e processar reunião |
|
||||
| `GET` | `/meetings/{id}/transcript` | Transcrição completa |
|
||||
| `GET` | `/meetings/{id}/analysis` | Análise da IA |
|
||||
| `POST` | `/speakers/enroll` | Enrollar speaker com voz |
|
||||
| `GET` | `/speakers/` | Listar speakers |
|
||||
| `GET` | `/admin/users` | (Admin) Listar usuários |
|
||||
|
||||
### Diarization API (`http://server:5060`)
|
||||
|
||||
| Método | Endpoint | Descrição |
|
||||
|--------|----------|-----------|
|
||||
| `POST` | `/transcribe` | Transcrever + diarizar áudio |
|
||||
| `POST` | `/enroll` | Enrollar voz de speaker |
|
||||
| `GET` | `/speakers` | Listar speakers cadastrados |
|
||||
| `DELETE` | `/speakers/{name}` | Remover speaker |
|
||||
| `GET` | `/health` | Health check |
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tecnologias
|
||||
|
||||
### Frontend
|
||||
| Tecnologia | Versão | Uso |
|
||||
|------------|--------|-----|
|
||||
| React | 18 | Framework UI |
|
||||
| Vite | 5 | Build tool |
|
||||
| TypeScript | 5 | Tipagem estática |
|
||||
| Zustand | 4 | Gerenciamento de estado |
|
||||
| Axios | 1.x | Cliente HTTP |
|
||||
| Workbox | 7 | PWA / Service Worker |
|
||||
| MediaRecorder API | — | Gravação de áudio |
|
||||
|
||||
### Backend
|
||||
| Tecnologia | Versão | Uso |
|
||||
|------------|--------|-----|
|
||||
| FastAPI | 0.110+ | Framework API |
|
||||
| SQLAlchemy | 2.x | ORM |
|
||||
| Alembic | 1.x | Migrações de banco |
|
||||
| PostgreSQL | 14+ | Banco de dados |
|
||||
| Celery | 5.x | Processamento assíncrono |
|
||||
| Redis | 7+ | Broker/Cache |
|
||||
| httpx | 0.27+ | Cliente HTTP assíncrono |
|
||||
|
||||
### Diarização & IA
|
||||
| Tecnologia | Uso |
|
||||
|------------|-----|
|
||||
| Whisper.cpp | Transcrição de áudio (GPU) |
|
||||
| SpeechBrain ECAPA-TDNN | Diarização de speakers |
|
||||
| pyannote.audio | Diarização (fallback) |
|
||||
| librosa | Processamento de áudio |
|
||||
| deepseek-v3.1 | Análise e resumo de reuniões |
|
||||
|
||||
### Infraestrutura
|
||||
| Tecnologia | Uso |
|
||||
|------------|-----|
|
||||
| Podman | Containerização |
|
||||
| Nginx | Reverse proxy / Servir frontend |
|
||||
| systemd | Gerenciamento de serviços |
|
||||
| Keycloak | SSO / Autenticação |
|
||||
|
||||
---
|
||||
|
||||
## 📄 Licença
|
||||
|
||||
Proprietário — ASAP Telecom. Todos os direitos reservados.
|
||||
|
||||
---
|
||||
|
||||
<p align="center">Desenvolvido com ❤️ pela equipe ASAP Telecom</p>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
WHISPER_SERVER_URL=http://host.docker.internal:9000
|
||||
OLLAMA_URL=http://host.docker.internal:11434
|
||||
OLLAMA_MODEL=llama3:8b
|
||||
DATABASE_URL=postgresql://meetingassistant:meetingassistant@postgres:5432/meeting_assistant
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
JWT_SECRET=change_this_to_a_strong_secret_key_in_production
|
||||
JWT_EXPIRE_MINUTES=60
|
||||
UPLOAD_MAX_SIZE_MB=500
|
||||
SPEAKER_CONFIDENCE_THRESHOLD=0.75
|
||||
AUDIO_STORAGE_PATH=/opt/Backend/meeting-assistant-api/storage/audio
|
||||
ALLOWED_ORIGINS=*
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
libpq-dev \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN mkdir -p /opt/Backend/meeting-assistant-api/storage/audio
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"]
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
# Meeting Assistant API
|
||||
|
||||
FastAPI orchestration backend for Meeting Assistant - middleware between Mobile App, Whisper, and Ollama.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Mobile App → FastAPI API → Whisper (transcription) + Ollama (AI analysis)
|
||||
→ PostgreSQL (data) + Redis (queues/cache)
|
||||
→ Celery Worker (async processing)
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
docker-compose up -d
|
||||
docker-compose exec api alembic upgrade head
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
- **Swagger UI:** http://localhost:8080/docs
|
||||
- **ReDoc:** http://localhost:8080/redoc
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Auth
|
||||
- `POST /api/auth/register` - Register new user
|
||||
- `POST /api/auth/login` - Login (returns JWT)
|
||||
- `POST /api/auth/refresh` - Refresh access token
|
||||
- `GET /api/auth/me` - Get current user
|
||||
|
||||
### Meetings
|
||||
- `POST /api/meetings/upload` - Upload audio + trigger processing
|
||||
- `GET /api/meetings` - List meetings
|
||||
- `GET /api/meetings/{id}` - Get meeting details
|
||||
- `GET /api/meetings/{id}/status` - Get processing status
|
||||
- `GET /api/meetings/{id}/analyses` - Get all analyses
|
||||
- `GET /api/meetings/{id}/analyses/{type}` - Get specific analysis
|
||||
- `PUT /api/meetings/{id}/participants/{label}` - Update speaker name
|
||||
- `DELETE /api/meetings/{id}` - Delete meeting
|
||||
|
||||
### WebSocket
|
||||
- `WS /ws/meetings/{meeting_id}` - Real-time status updates
|
||||
|
||||
### Health
|
||||
- `GET /health` - Health check
|
||||
|
||||
## Processing Pipeline
|
||||
|
||||
1. Audio uploaded → saved to storage/
|
||||
2. Celery task queued
|
||||
3. Audio sent to Whisper for diarized transcription
|
||||
4. Transcription sent to Ollama for parallel analyses:
|
||||
- `minutes` - Structured meeting minutes
|
||||
- `sentiment` - Emotional tone analysis
|
||||
- `psychological` - Behavioral patterns
|
||||
- `communication` - Communication quality feedback
|
||||
- `summary` - Executive summary
|
||||
5. Status updated to `completed`
|
||||
6. WebSocket notification sent
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example` for all configuration options.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# View logs
|
||||
docker-compose logs -f api
|
||||
|
||||
# Run migrations
|
||||
docker-compose exec api alembic upgrade head
|
||||
|
||||
# Create new migration
|
||||
docker-compose exec api alembic revision --autogenerate -m "description"
|
||||
|
||||
# Check celery worker
|
||||
docker-compose logs -f worker
|
||||
```
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
version_path_separator = os
|
||||
sqlalchemy.url = postgresql://meetingassistant:meetingassistant@postgres:5432/meeting_assistant
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
import os
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
from alembic import context
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
database_url = os.environ.get("DATABASE_URL", "")
|
||||
if database_url:
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
from app.database import Base
|
||||
from app.models.user import User
|
||||
from app.models.meeting import Meeting
|
||||
from app.models.analysis import Analysis
|
||||
from app.models.participant import MeetingParticipant
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"""initial_schema
|
||||
|
||||
Revision ID: 5965c1a94621
|
||||
Revises:
|
||||
Create Date: 2026-04-13 20:25:03.459694
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = '5965c1a94621'
|
||||
down_revision: Union[str, None] = None
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('users',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('email', sa.String(), nullable=False),
|
||||
sa.Column('password_hash', sa.String(), nullable=False),
|
||||
sa.Column('name', sa.String(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
|
||||
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
|
||||
op.create_table('meetings',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('title', sa.String(), nullable=True),
|
||||
sa.Column('date', sa.DateTime(), nullable=False),
|
||||
sa.Column('duration', sa.Integer(), nullable=True),
|
||||
sa.Column('audio_path', sa.String(), nullable=True),
|
||||
sa.Column('status', sa.Enum('uploading', 'processing', 'transcribing', 'analyzing', 'completed', 'error', name='meetingstatus'), nullable=False),
|
||||
sa.Column('transcription', sa.JSON(), nullable=True),
|
||||
sa.Column('user_id', sa.UUID(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_meetings_id'), 'meetings', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_meetings_user_id'), 'meetings', ['user_id'], unique=False)
|
||||
op.create_table('analyses',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('meeting_id', sa.UUID(), nullable=False),
|
||||
sa.Column('type', sa.Enum('minutes', 'sentiment', 'psychological', 'communication', 'summary', name='analysistype'), nullable=False),
|
||||
sa.Column('content', sa.JSON(), nullable=False),
|
||||
sa.Column('model_used', sa.String(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['meeting_id'], ['meetings.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_analyses_id'), 'analyses', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_analyses_meeting_id'), 'analyses', ['meeting_id'], unique=False)
|
||||
op.create_table('meeting_participants',
|
||||
sa.Column('id', sa.UUID(), nullable=False),
|
||||
sa.Column('meeting_id', sa.UUID(), nullable=False),
|
||||
sa.Column('speaker_label', sa.String(), nullable=False),
|
||||
sa.Column('voice_profile_id', sa.UUID(), nullable=True),
|
||||
sa.Column('identified_name', sa.String(), nullable=True),
|
||||
sa.Column('confidence_score', sa.Float(), nullable=True),
|
||||
sa.Column('talk_time_seconds', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['meeting_id'], ['meetings.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
op.create_index(op.f('ix_meeting_participants_id'), 'meeting_participants', ['id'], unique=False)
|
||||
op.create_index(op.f('ix_meeting_participants_meeting_id'), 'meeting_participants', ['meeting_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_meeting_participants_meeting_id'), table_name='meeting_participants')
|
||||
op.drop_index(op.f('ix_meeting_participants_id'), table_name='meeting_participants')
|
||||
op.drop_table('meeting_participants')
|
||||
op.drop_index(op.f('ix_analyses_meeting_id'), table_name='analyses')
|
||||
op.drop_index(op.f('ix_analyses_id'), table_name='analyses')
|
||||
op.drop_table('analyses')
|
||||
op.drop_index(op.f('ix_meetings_user_id'), table_name='meetings')
|
||||
op.drop_index(op.f('ix_meetings_id'), table_name='meetings')
|
||||
op.drop_table('meetings')
|
||||
op.drop_index(op.f('ix_users_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_users_email'), table_name='users')
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
from pydantic_settings import BaseSettings
|
||||
|
||||
class Settings(BaseSettings):
|
||||
WHISPER_SERVER_URL: str = "http://localhost:9000"
|
||||
OLLAMA_URL: str = "http://localhost:11434"
|
||||
OLLAMA_MODEL: str = "llama3:8b"
|
||||
OLLAMA_API_KEY: str = ""
|
||||
DATABASE_URL: str = "postgresql://meetingassistant:meetingassistant@postgres:5432/meeting_assistant"
|
||||
REDIS_URL: str = "redis://redis:6379/0"
|
||||
JWT_SECRET: str = "change_this_to_a_strong_secret_key_in_production"
|
||||
JWT_EXPIRE_MINUTES: int = 60
|
||||
JWT_REFRESH_EXPIRE_DAYS: int = 7
|
||||
UPLOAD_MAX_SIZE_MB: int = 500
|
||||
SPEAKER_CONFIDENCE_THRESHOLD: float = 0.75
|
||||
AUDIO_STORAGE_PATH: str = "/opt/Backend/meeting-assistant-api/storage/audio"
|
||||
ALLOWED_ORIGINS: str = "*"
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
extra = "allow"
|
||||
|
||||
settings = Settings()
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker, DeclarativeBase
|
||||
from app.config import settings
|
||||
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import logging
|
||||
import json
|
||||
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from contextlib import asynccontextmanager
|
||||
from app.routers import auth, meetings, analyses
|
||||
from app.config import settings
|
||||
from app.services.websocket_service import manager
|
||||
|
||||
|
||||
class JSONFormatter(logging.Formatter):
|
||||
def format(self, record):
|
||||
log_record = {
|
||||
"level": record.levelname,
|
||||
"message": record.getMessage(),
|
||||
"logger": record.name,
|
||||
"time": self.formatTime(record),
|
||||
}
|
||||
if record.exc_info:
|
||||
log_record["exception"] = self.formatException(record.exc_info)
|
||||
return json.dumps(log_record, ensure_ascii=False)
|
||||
|
||||
|
||||
handler = logging.StreamHandler()
|
||||
handler.setFormatter(JSONFormatter())
|
||||
logging.basicConfig(handlers=[handler], level=logging.INFO, force=True)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info(json.dumps({"event": "startup", "message": "Meeting Assistant API starting"}))
|
||||
yield
|
||||
logger.info(json.dumps({"event": "shutdown", "message": "Meeting Assistant API stopping"}))
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Meeting Assistant API",
|
||||
description="Orchestration server for Meeting Assistant - middleware between Mobile App and AI services",
|
||||
version="1.0.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
origins = settings.ALLOWED_ORIGINS.split(",") if settings.ALLOWED_ORIGINS != "*" else ["*"]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(meetings.router, prefix="/api/meetings", tags=["meetings"])
|
||||
app.include_router(analyses.router, prefix="/api", tags=["analyses"])
|
||||
|
||||
|
||||
@app.get("/health", tags=["health"])
|
||||
async def health_check():
|
||||
return {
|
||||
"status": "healthy",
|
||||
"service": "meeting-assistant-api",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
@app.websocket("/ws/meetings/{meeting_id}")
|
||||
async def websocket_endpoint(websocket: WebSocket, meeting_id: str):
|
||||
await manager.connect(websocket, meeting_id)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
await manager.send_message(meeting_id, {"type": "ping", "data": data})
|
||||
except WebSocketDisconnect:
|
||||
manager.disconnect(websocket, meeting_id)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
logger.error(json.dumps({
|
||||
"event": "unhandled_exception",
|
||||
"error": str(exc),
|
||||
"path": str(request.url),
|
||||
"method": request.method,
|
||||
}))
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
from app.models.user import User
|
||||
from app.models.meeting import Meeting, MeetingStatus
|
||||
from app.models.analysis import Analysis, AnalysisType
|
||||
from app.models.participant import MeetingParticipant
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
import uuid
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, DateTime, JSON, Enum, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class AnalysisType(str, enum.Enum):
|
||||
minutes = "minutes"
|
||||
sentiment = "sentiment"
|
||||
psychological = "psychological"
|
||||
communication = "communication"
|
||||
summary = "summary"
|
||||
|
||||
|
||||
class Analysis(Base):
|
||||
__tablename__ = "analyses"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
meeting_id = Column(UUID(as_uuid=True), ForeignKey("meetings.id"), nullable=False, index=True)
|
||||
type = Column(Enum(AnalysisType), nullable=False)
|
||||
content = Column(JSON, nullable=False, default=dict)
|
||||
model_used = Column(String, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
meeting = relationship("Meeting", back_populates="analyses")
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import uuid
|
||||
import enum
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, DateTime, Integer, JSON, Enum, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
class MeetingStatus(str, enum.Enum):
|
||||
receiving_chunks = "receiving_chunks"
|
||||
uploading = "uploading"
|
||||
processing = "processing"
|
||||
transcribing = "transcribing"
|
||||
analyzing = "analyzing"
|
||||
completed = "completed"
|
||||
error = "error"
|
||||
|
||||
class Meeting(Base):
|
||||
__tablename__ = "meetings"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
title = Column(String, nullable=True)
|
||||
date = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
duration = Column(Integer, nullable=True)
|
||||
audio_path = Column(String, nullable=True)
|
||||
status = Column(Enum(MeetingStatus), default=MeetingStatus.uploading, nullable=False)
|
||||
transcription = Column(JSON, nullable=True)
|
||||
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
user = relationship("User", back_populates="meetings")
|
||||
analyses = relationship("Analysis", back_populates="meeting", cascade="all, delete-orphan")
|
||||
participants = relationship("MeetingParticipant", back_populates="meeting", cascade="all, delete-orphan")
|
||||
|
||||
@property
|
||||
def owner_email(self):
|
||||
return self.user.email if self.user else None
|
||||
|
||||
@property
|
||||
def owner_name(self):
|
||||
return self.user.name if self.user else None
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, DateTime, Integer, Float, ForeignKey
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class MeetingParticipant(Base):
|
||||
__tablename__ = "meeting_participants"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
meeting_id = Column(UUID(as_uuid=True), ForeignKey("meetings.id"), nullable=False, index=True)
|
||||
speaker_label = Column(String, nullable=False)
|
||||
voice_profile_id = Column(UUID(as_uuid=True), nullable=True)
|
||||
identified_name = Column(String, nullable=True)
|
||||
confidence_score = Column(Float, nullable=True)
|
||||
talk_time_seconds = Column(Integer, nullable=True)
|
||||
|
||||
meeting = relationship("Meeting", back_populates="participants")
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, DateTime, JSON
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.database import Base
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4, index=True)
|
||||
email = Column(String, unique=True, index=True, nullable=False)
|
||||
password_hash = Column(String, nullable=False)
|
||||
name = Column(String, nullable=False)
|
||||
roles = Column(JSON, default=list, nullable=False, server_default="[]")
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
|
||||
meetings = relationship("Meeting", back_populates="user", cascade="all, delete-orphan")
|
||||
|
|
@ -0,0 +1 @@
|
|||
from app.routers import auth, meetings, analyses
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
router = APIRouter()
|
||||
# Analysis endpoints are served through /api/meetings/{id}/analyses
|
||||
# See app/routers/meetings.py for implementation
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
from app.database import get_db
|
||||
from app.schemas.auth import UserRegister, UserLogin, TokenRefresh, TokenResponse, UserResponse
|
||||
from app.services.auth_service import (
|
||||
register_user,
|
||||
login_user,
|
||||
refresh_tokens,
|
||||
get_current_user,
|
||||
get_or_create_sso_user,
|
||||
)
|
||||
from app.utils.keycloak_auth import validate_keycloak_token, has_role, extract_user_info
|
||||
from app.utils.security import create_access_token, create_refresh_token
|
||||
|
||||
MAIA_ROLES = ["maia.app", "maia.admin"]
|
||||
|
||||
router = APIRouter()
|
||||
_bearer = HTTPBearer()
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=201)
|
||||
def register(data: UserRegister, db: Session = Depends(get_db)):
|
||||
return register_user(db, data.email, data.password, data.name)
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
def login(data: UserLogin, db: Session = Depends(get_db)):
|
||||
return login_user(db, data.email, data.password)
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
def refresh(data: TokenRefresh):
|
||||
return refresh_tokens(data.refresh_token)
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
def me(current_user=Depends(get_current_user)):
|
||||
return current_user
|
||||
|
||||
class KeycloakAuthRequest(BaseModel):
|
||||
kc_token: str
|
||||
|
||||
@router.post("/keycloak", response_model=TokenResponse)
|
||||
def keycloak_login(data: KeycloakAuthRequest, db: Session = Depends(get_db)):
|
||||
"""Exchange a Keycloak RS256 access token for internal HS256 JWT tokens."""
|
||||
payload = validate_keycloak_token(data.kc_token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Token Keycloak invalido ou expirado")
|
||||
|
||||
if not has_role(payload, "maia.app"):
|
||||
raise HTTPException(status_code=403, detail="Acesso negado: role maia.app nao encontrada")
|
||||
|
||||
user_info = extract_user_info(payload)
|
||||
|
||||
# Collect all maia.* roles the user has
|
||||
user_roles = [r for r in MAIA_ROLES if has_role(payload, r)]
|
||||
|
||||
user = get_or_create_sso_user(db, user_info, roles=user_roles)
|
||||
|
||||
access_token = create_access_token(str(user.id))
|
||||
refresh_token = create_refresh_token(str(user.id))
|
||||
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}
|
||||
|
|
@ -0,0 +1,378 @@
|
|||
from datetime import datetime
|
||||
import uuid
|
||||
from fastapi.responses import FileResponse
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, Form, Query, Body
|
||||
from sqlalchemy.orm import Session, joinedload
|
||||
from app.database import get_db
|
||||
from app.models.meeting import Meeting, MeetingStatus
|
||||
from app.models.participant import MeetingParticipant
|
||||
from app.schemas.meeting import MeetingResponse, MeetingStatusResponse, ParticipantUpdate, ParticipantResponse
|
||||
from app.services.auth_service import get_current_user, is_admin
|
||||
from app.services.upload_service import save_audio_file
|
||||
from app.services.chunk_service import save_chunk, assemble_chunks, cleanup_chunks
|
||||
from app.tasks.processing import process_meeting
|
||||
import httpx
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
AUDIO_STORAGE = "/opt/Backend/meeting-assistant-api/storage/audio"
|
||||
|
||||
def _get_meeting_for_user(meeting_id: str, current_user, db: Session) -> Meeting:
|
||||
"""Return meeting if user owns it or is admin. Raises 404 otherwise."""
|
||||
q = db.query(Meeting).options(joinedload(Meeting.user)).filter(Meeting.id == meeting_id)
|
||||
if not is_admin(current_user):
|
||||
q = q.filter(Meeting.user_id == current_user.id)
|
||||
meeting = q.first()
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||
return meeting
|
||||
|
||||
|
||||
@router.post("/upload", response_model=MeetingResponse, status_code=201)
|
||||
async def upload_meeting(
|
||||
file: UploadFile = File(...),
|
||||
title: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
meeting_id = str(uuid.uuid4())
|
||||
audio_path = await save_audio_file(file, meeting_id)
|
||||
meeting = Meeting(
|
||||
id=meeting_id, title=title or file.filename or "Meeting",
|
||||
audio_path=audio_path, status=MeetingStatus.uploading, user_id=current_user.id,
|
||||
)
|
||||
db.add(meeting)
|
||||
db.commit()
|
||||
db.refresh(meeting)
|
||||
process_meeting.delay(meeting_id)
|
||||
logger.info(f"Meeting {meeting_id} created and queued for processing")
|
||||
return meeting
|
||||
|
||||
|
||||
@router.post("/start", response_model=MeetingResponse, status_code=201)
|
||||
async def start_meeting(
|
||||
title: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
meeting_id = str(uuid.uuid4())
|
||||
from datetime import timezone, timedelta
|
||||
br_tz = timezone(timedelta(hours=-3))
|
||||
now_br = datetime.now(br_tz)
|
||||
default_title = f"{current_user.name} {now_br.strftime('%d/%m/%Y, %H:%M')}"
|
||||
meeting = Meeting(
|
||||
id=meeting_id, title=title or default_title,
|
||||
status=MeetingStatus.receiving_chunks, user_id=current_user.id,
|
||||
)
|
||||
db.add(meeting)
|
||||
db.commit()
|
||||
db.refresh(meeting)
|
||||
logger.info(f"Meeting {meeting_id} started (chunk mode)")
|
||||
return meeting
|
||||
|
||||
|
||||
@router.get("", response_model=List[MeetingResponse])
|
||||
def list_meetings(
|
||||
skip: int = 0,
|
||||
limit: int = 50,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
"""List meetings. Admins see all meetings with owner info; users see only their own."""
|
||||
q = db.query(Meeting).options(joinedload(Meeting.user))
|
||||
if not is_admin(current_user):
|
||||
q = q.filter(Meeting.user_id == current_user.id)
|
||||
meetings = q.order_by(Meeting.created_at.desc()).offset(skip).limit(limit).all()
|
||||
return meetings
|
||||
|
||||
|
||||
@router.post("/{meeting_id}/chunks", status_code=200)
|
||||
async def upload_chunk(
|
||||
meeting_id: str,
|
||||
file: UploadFile = File(...),
|
||||
chunk_index: int = Form(...),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
meeting = db.query(Meeting).filter(
|
||||
Meeting.id == meeting_id, Meeting.user_id == current_user.id
|
||||
).first()
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||
if meeting.status not in (MeetingStatus.receiving_chunks, MeetingStatus.uploading):
|
||||
raise HTTPException(status_code=409, detail=f"Meeting not accepting chunks (status: {meeting.status})")
|
||||
ext = os.path.splitext(file.filename or "chunk.webm")[1].lower() or ".webm"
|
||||
data = await file.read()
|
||||
save_chunk(data, meeting_id, chunk_index, ext)
|
||||
logger.info(f"Chunk {chunk_index} received for meeting {meeting_id} ({len(data)} bytes)")
|
||||
return {"meeting_id": meeting_id, "chunk_index": chunk_index, "size": len(data)}
|
||||
|
||||
|
||||
@router.post("/{meeting_id}/finalize", response_model=MeetingResponse)
|
||||
async def finalize_meeting(
|
||||
meeting_id: str,
|
||||
title: Optional[str] = Query(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
meeting = db.query(Meeting).filter(
|
||||
Meeting.id == meeting_id, Meeting.user_id == current_user.id
|
||||
).first()
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||
if title and not meeting.title:
|
||||
meeting.title = title
|
||||
try:
|
||||
audio_path = assemble_chunks(meeting_id)
|
||||
meeting.audio_path = audio_path
|
||||
meeting.status = MeetingStatus.uploading
|
||||
db.commit()
|
||||
cleanup_chunks(meeting_id)
|
||||
process_meeting.delay(meeting_id)
|
||||
logger.info(f"Meeting {meeting_id} finalized and queued for processing")
|
||||
except Exception as e:
|
||||
meeting.status = MeetingStatus.error
|
||||
db.commit()
|
||||
logger.error(f"Failed to finalize meeting {meeting_id}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to assemble audio: {str(e)}")
|
||||
db.refresh(meeting)
|
||||
return meeting
|
||||
|
||||
|
||||
@router.get("/{meeting_id}", response_model=MeetingResponse)
|
||||
def get_meeting(
|
||||
meeting_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
return _get_meeting_for_user(meeting_id, current_user, db)
|
||||
|
||||
|
||||
@router.get("/{meeting_id}/status", response_model=MeetingStatusResponse)
|
||||
def get_meeting_status(
|
||||
meeting_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
return _get_meeting_for_user(meeting_id, current_user, db)
|
||||
|
||||
|
||||
@router.get("/{meeting_id}/audio")
|
||||
def get_meeting_audio(
|
||||
meeting_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
"""Stream the audio file for a meeting."""
|
||||
meeting = _get_meeting_for_user(meeting_id, current_user, db)
|
||||
if not meeting.audio_path or not os.path.exists(meeting.audio_path):
|
||||
# Try common extensions
|
||||
for ext in [".webm", ".wav", ".m4a", ".mp3", ".ogg"]:
|
||||
candidate = os.path.join(AUDIO_STORAGE, f"{meeting_id}{ext}")
|
||||
if os.path.exists(candidate):
|
||||
meeting.audio_path = candidate
|
||||
break
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Audio file not found")
|
||||
ext = os.path.splitext(meeting.audio_path)[1].lower()
|
||||
mime_map = {".webm": "audio/webm", ".wav": "audio/wav", ".mp3": "audio/mpeg",
|
||||
".m4a": "audio/mp4", ".ogg": "audio/ogg"}
|
||||
media_type = mime_map.get(ext, "audio/webm")
|
||||
return FileResponse(meeting.audio_path, media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/{meeting_id}/analyses")
|
||||
def get_meeting_analyses(
|
||||
meeting_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
meeting = _get_meeting_for_user(meeting_id, current_user, db)
|
||||
return meeting.analyses
|
||||
|
||||
|
||||
@router.get("/{meeting_id}/analyses/{analysis_type}")
|
||||
def get_meeting_analysis_by_type(
|
||||
meeting_id: str,
|
||||
analysis_type: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
meeting = _get_meeting_for_user(meeting_id, current_user, db)
|
||||
from app.models.analysis import AnalysisType, Analysis
|
||||
try:
|
||||
atype = AnalysisType(analysis_type)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400,
|
||||
detail=f"Invalid analysis type '{analysis_type}'. Valid: minutes, sentiment, psychological, communication, summary")
|
||||
analysis = db.query(Analysis).filter(
|
||||
Analysis.meeting_id == meeting_id, Analysis.type == atype
|
||||
).first()
|
||||
if not analysis:
|
||||
raise HTTPException(status_code=404, detail=f"Analysis '{analysis_type}' not yet available")
|
||||
return analysis
|
||||
|
||||
|
||||
@router.put("/{meeting_id}/participants/{speaker_label}", response_model=ParticipantResponse)
|
||||
def update_participant(
|
||||
meeting_id: str,
|
||||
speaker_label: str,
|
||||
data: ParticipantUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
meeting = _get_meeting_for_user(meeting_id, current_user, db)
|
||||
participant = db.query(MeetingParticipant).filter(
|
||||
MeetingParticipant.meeting_id == meeting_id,
|
||||
MeetingParticipant.speaker_label == speaker_label,
|
||||
).first()
|
||||
if not participant:
|
||||
participant = MeetingParticipant(meeting_id=meeting_id, speaker_label=speaker_label)
|
||||
db.add(participant)
|
||||
if data.identified_name is not None:
|
||||
participant.identified_name = data.identified_name
|
||||
if data.confidence_score is not None:
|
||||
participant.confidence_score = data.confidence_score
|
||||
db.commit()
|
||||
db.refresh(participant)
|
||||
return participant
|
||||
|
||||
|
||||
|
||||
@router.post("/{meeting_id}/participants/{speaker_label}/enroll", response_model=ParticipantResponse)
|
||||
async def enroll_participant(
|
||||
meeting_id: str,
|
||||
speaker_label: str,
|
||||
name: str = Body(...),
|
||||
email: str = Body(None),
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
"""Enroll a speaker voice profile and update their name in the transcript."""
|
||||
import subprocess
|
||||
import json as _json
|
||||
import tempfile
|
||||
|
||||
meeting = _get_meeting_for_user(meeting_id, current_user, db)
|
||||
|
||||
if not meeting.transcription:
|
||||
raise HTTPException(status_code=400, detail="No transcription available")
|
||||
|
||||
if not meeting.audio_path or not os.path.exists(meeting.audio_path):
|
||||
raise HTTPException(status_code=404, detail="Meeting audio file not found")
|
||||
|
||||
segments = meeting.transcription.get("segments", [])
|
||||
speaker_segments = [s for s in segments if s.get("speaker") == speaker_label]
|
||||
if not speaker_segments:
|
||||
raise HTTPException(status_code=404, detail=f"Speaker '{speaker_label}' not found in transcript")
|
||||
|
||||
valid_segs = [s for s in speaker_segments if (s.get("end", 0) - s.get("start", 0)) >= 0.5][:12]
|
||||
|
||||
tmp_path = None
|
||||
profile_id = None
|
||||
try:
|
||||
if valid_segs:
|
||||
filter_parts = []
|
||||
out_parts = []
|
||||
for i, seg in enumerate(valid_segs):
|
||||
start = seg["start"]
|
||||
end = seg["end"]
|
||||
filter_parts.append(f"[0:a]atrim={start}:{end},asetpts=PTS-STARTPTS[a{i}]")
|
||||
out_parts.append(f"[a{i}]")
|
||||
|
||||
n = len(filter_parts)
|
||||
filter_str = ";".join(filter_parts) + ";" + "".join(out_parts) + f"concat=n={n}:v=0:a=1[out]"
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", meeting.audio_path, "-filter_complex", filter_str,
|
||||
"-map", "[out]", "-ar", "16000", "-ac", "1", tmp_path],
|
||||
capture_output=True, timeout=120,
|
||||
)
|
||||
audio_source = tmp_path if result.returncode == 0 else meeting.audio_path
|
||||
else:
|
||||
audio_source = meeting.audio_path
|
||||
|
||||
whisper_url = settings.WHISPER_SERVER_URL
|
||||
with open(audio_source, "rb") as audio_f:
|
||||
audio_bytes = audio_f.read()
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as http:
|
||||
resp = await http.post(
|
||||
f"{whisper_url}/api/v2/enroll",
|
||||
files={"file": (f"{name.replace(' ', '_')}.wav", audio_bytes, "audio/wav")},
|
||||
data={
|
||||
"name": name,
|
||||
"email": email or "",
|
||||
"metadata": _json.dumps({"source_meeting": meeting_id, "original_label": speaker_label}),
|
||||
},
|
||||
)
|
||||
if resp.status_code in (200, 201):
|
||||
profile_data = resp.json()
|
||||
profile_id = profile_data.get("id")
|
||||
else:
|
||||
logger.warning(f"Enrollment API error {resp.status_code}: {resp.text[:200]}")
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Enrollment process error: {e}")
|
||||
finally:
|
||||
if tmp_path and os.path.exists(tmp_path):
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
participant = db.query(MeetingParticipant).filter(
|
||||
MeetingParticipant.meeting_id == meeting.id,
|
||||
MeetingParticipant.speaker_label == speaker_label,
|
||||
).first()
|
||||
if not participant:
|
||||
participant = MeetingParticipant(meeting_id=meeting.id, speaker_label=speaker_label)
|
||||
db.add(participant)
|
||||
|
||||
participant.identified_name = name
|
||||
if profile_id:
|
||||
try:
|
||||
from uuid import UUID as _UUID
|
||||
participant.voice_profile_id = _UUID(profile_id)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
updated_segments = [
|
||||
{**seg, "speaker": name} if seg.get("speaker") == speaker_label else seg
|
||||
for seg in segments
|
||||
]
|
||||
meeting.transcription = {**meeting.transcription, "segments": updated_segments}
|
||||
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
flag_modified(meeting, "transcription")
|
||||
|
||||
db.commit()
|
||||
db.refresh(participant)
|
||||
|
||||
logger.info(f"Speaker '{speaker_label}' identified as '{name}', profile_id={profile_id}")
|
||||
return participant
|
||||
|
||||
@router.delete("/{meeting_id}", status_code=204)
|
||||
def delete_meeting(
|
||||
meeting_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
current_user=Depends(get_current_user),
|
||||
):
|
||||
"""Delete a meeting. Only maia.admin users can delete."""
|
||||
if not is_admin(current_user):
|
||||
raise HTTPException(status_code=403, detail="Apenas administradores podem excluir reunioes")
|
||||
meeting = db.query(Meeting).filter(Meeting.id == meeting_id).first()
|
||||
if not meeting:
|
||||
raise HTTPException(status_code=404, detail="Meeting not found")
|
||||
db.delete(meeting)
|
||||
db.commit()
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from app.schemas.auth import UserRegister, UserLogin, TokenRefresh, TokenResponse, UserResponse
|
||||
from app.schemas.meeting import MeetingResponse, MeetingStatusResponse, ParticipantUpdate, ParticipantResponse
|
||||
from app.schemas.analysis import AnalysisResponse
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
from pydantic import BaseModel
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from app.models.analysis import AnalysisType
|
||||
|
||||
|
||||
class AnalysisResponse(BaseModel):
|
||||
id: UUID
|
||||
meeting_id: UUID
|
||||
type: AnalysisType
|
||||
content: Any
|
||||
model_used: str
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from typing import Optional, List
|
||||
|
||||
class UserRegister(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
name: str
|
||||
|
||||
class UserLogin(BaseModel):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
class TokenRefresh(BaseModel):
|
||||
refresh_token: str
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: UUID
|
||||
email: str
|
||||
name: str
|
||||
roles: List[str] = []
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
from pydantic import BaseModel
|
||||
from typing import Optional, List, Any
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
from app.models.meeting import MeetingStatus
|
||||
|
||||
class MeetingResponse(BaseModel):
|
||||
id: UUID
|
||||
title: Optional[str] = None
|
||||
date: datetime
|
||||
duration: Optional[int] = None
|
||||
status: MeetingStatus
|
||||
transcription: Optional[Any] = None
|
||||
user_id: UUID
|
||||
owner_email: Optional[str] = None
|
||||
owner_name: Optional[str] = None
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class MeetingStatusResponse(BaseModel):
|
||||
id: UUID
|
||||
status: MeetingStatus
|
||||
title: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ParticipantUpdate(BaseModel):
|
||||
identified_name: Optional[str] = None
|
||||
confidence_score: Optional[float] = None
|
||||
|
||||
class ParticipantResponse(BaseModel):
|
||||
id: UUID
|
||||
meeting_id: UUID
|
||||
speaker_label: str
|
||||
voice_profile_id: Optional[UUID] = None
|
||||
identified_name: Optional[str] = None
|
||||
confidence_score: Optional[float] = None
|
||||
talk_time_seconds: Optional[int] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
from sqlalchemy.orm import Session
|
||||
from fastapi import HTTPException, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from app.models.user import User
|
||||
from app.utils.security import (
|
||||
hash_password,
|
||||
verify_password,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
)
|
||||
from app.database import get_db
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
def register_user(db: Session, email: str, password: str, name: str) -> User:
|
||||
existing = db.query(User).filter(User.email == email).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="Email already registered")
|
||||
user = User(email=email, password_hash=hash_password(password), name=name, roles=[])
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
def login_user(db: Session, email: str, password: str) -> dict:
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if not user or not verify_password(password, user.password_hash):
|
||||
raise HTTPException(status_code=401, detail="Invalid email or password")
|
||||
access_token = create_access_token(str(user.id))
|
||||
refresh_token = create_refresh_token(str(user.id))
|
||||
return {"access_token": access_token, "refresh_token": refresh_token, "token_type": "bearer"}
|
||||
|
||||
def refresh_tokens(refresh_token: str) -> dict:
|
||||
payload = decode_token(refresh_token)
|
||||
if not payload or payload.get("type") != "refresh":
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
user_id = payload.get("sub")
|
||||
access_token = create_access_token(user_id)
|
||||
new_refresh = create_refresh_token(user_id)
|
||||
return {"access_token": access_token, "refresh_token": new_refresh, "token_type": "bearer"}
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(security),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
token = credentials.credentials
|
||||
payload = decode_token(token)
|
||||
if not payload or payload.get("type") != "access":
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired access token")
|
||||
user_id = payload.get("sub")
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=401, detail="User not found")
|
||||
return user
|
||||
|
||||
def is_admin(user: User) -> bool:
|
||||
"""Check if user has maia.admin role."""
|
||||
return "maia.admin" in (user.roles or [])
|
||||
|
||||
def get_or_create_sso_user(db: Session, user_info: dict, roles: list = None) -> User:
|
||||
"""Look up user by email (SSO login). Create or update if found."""
|
||||
import uuid as _uuid
|
||||
email = user_info.get("email", "")
|
||||
name = user_info.get("name", "") or email or "SSO User"
|
||||
roles = roles or []
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if user:
|
||||
user.name = name
|
||||
# Merge KC roles with existing DB roles (don't downgrade if KC lacks a role)
|
||||
existing = set(user.roles or [])
|
||||
incoming = set(roles or [])
|
||||
user.roles = sorted(existing | incoming)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
placeholder_hash = hash_password(str(_uuid.uuid4()))
|
||||
user = User(email=email, password_hash=placeholder_hash, name=name, roles=roles)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
import os
|
||||
import subprocess
|
||||
import glob
|
||||
import logging
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_chunks_dir(meeting_id: str) -> str:
|
||||
path = os.path.join(settings.AUDIO_STORAGE_PATH, "chunks", meeting_id)
|
||||
os.makedirs(path, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def save_chunk(chunk_data: bytes, meeting_id: str, chunk_index: int, ext: str = ".webm") -> str:
|
||||
chunks_dir = get_chunks_dir(meeting_id)
|
||||
filename = f"chunk_{chunk_index:06d}{ext}"
|
||||
path = os.path.join(chunks_dir, filename)
|
||||
with open(path, "wb") as f:
|
||||
f.write(chunk_data)
|
||||
return path
|
||||
|
||||
|
||||
def assemble_chunks(meeting_id: str) -> str:
|
||||
"""
|
||||
Assemble all chunks into a single WAV file for Whisper processing.
|
||||
|
||||
Timeslice WebM chunks (from a single continuous MediaRecorder session) form a
|
||||
valid WebM stream when binary-concatenated in order. We concatenate them and
|
||||
then convert to 16kHz mono WAV with ffmpeg.
|
||||
Falls back to ffmpeg concat demuxer if binary concat fails.
|
||||
"""
|
||||
chunks_dir = get_chunks_dir(meeting_id)
|
||||
chunk_files = sorted(glob.glob(os.path.join(chunks_dir, "chunk_*")))
|
||||
if not chunk_files:
|
||||
raise ValueError(f"No chunks found for meeting {meeting_id}")
|
||||
|
||||
output_path = os.path.join(settings.AUDIO_STORAGE_PATH, f"{meeting_id}.wav")
|
||||
|
||||
if len(chunk_files) == 1:
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", chunk_files[0],
|
||||
"-ar", "16000", "-ac", "1", "-sample_fmt", "s16", output_path],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error(f"ffmpeg single-chunk convert failed: {result.stderr}")
|
||||
raise RuntimeError(f"Failed to convert audio chunk: {result.stderr}")
|
||||
else:
|
||||
# Binary-concatenate timeslice WebM chunks (they are sequential fragments
|
||||
# of the same MediaRecorder stream — binary concat = valid WebM stream)
|
||||
raw_concat = os.path.join(chunks_dir, "concat_raw.webm")
|
||||
with open(raw_concat, "wb") as out:
|
||||
for cf in chunk_files:
|
||||
with open(cf, "rb") as inp:
|
||||
out.write(inp.read())
|
||||
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-y", "-i", raw_concat,
|
||||
"-ar", "16000", "-ac", "1", "-sample_fmt", "s16", output_path],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning(f"Binary concat failed ({result.stderr[:200]}), trying concat demuxer...")
|
||||
concat_list = os.path.join(chunks_dir, "concat.txt")
|
||||
with open(concat_list, "w") as f:
|
||||
for cf in chunk_files:
|
||||
f.write(f"file '{cf}'\n")
|
||||
result2 = subprocess.run(
|
||||
["ffmpeg", "-y", "-f", "concat", "-safe", "0", "-i", concat_list,
|
||||
"-ar", "16000", "-ac", "1", "-sample_fmt", "s16", output_path],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result2.returncode != 0:
|
||||
logger.error(f"Both assembly methods failed: {result2.stderr}")
|
||||
raise RuntimeError(f"Failed to assemble audio chunks: {result2.stderr}")
|
||||
|
||||
logger.info(f"Assembled {len(chunk_files)} chunks -> {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
def cleanup_chunks(meeting_id: str):
|
||||
"""Remove chunk files after successful assembly."""
|
||||
import shutil
|
||||
chunks_dir = get_chunks_dir(meeting_id)
|
||||
if os.path.exists(chunks_dir):
|
||||
shutil.rmtree(chunks_dir)
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
import httpx
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROMPTS = {
|
||||
"minutes": (
|
||||
"Voce e um assistente especializado em reunioes corporativas. "
|
||||
"Com base na transcricao a seguir, gere uma ata estruturada em JSON contendo: "
|
||||
"title (string), participants (array de objetos com name e role), "
|
||||
"agenda (array de strings), decisions (array de strings), "
|
||||
"action_items (array de objetos com description, responsible e deadline), "
|
||||
"next_steps (array de strings), open_points (array de strings). "
|
||||
"Retorne APENAS o JSON valido, sem texto adicional. "
|
||||
"Transcricao: {transcription}"
|
||||
),
|
||||
"sentiment": (
|
||||
"Analise o tom emocional desta reuniao e retorne um JSON com: "
|
||||
"overall_sentiment (positive/neutral/negative), "
|
||||
"timeline (array de objetos com time_range, sentiment e notes), "
|
||||
"participants_sentiment (array de objetos com speaker, sentiment e details), "
|
||||
"tension_moments (array de strings), "
|
||||
"consensus_moments (array de strings), "
|
||||
"participation_metrics (array de objetos com speaker, talk_percentage e interventions_count). "
|
||||
"Retorne APENAS o JSON valido. "
|
||||
"Transcricao: {transcription}"
|
||||
),
|
||||
"psychological": (
|
||||
"Analise os padroes comportamentais dos participantes e retorne um JSON com: "
|
||||
"participants_profiles (array de objetos com speaker, name, communication_style, "
|
||||
"leadership_tendencies, disengagement_signs, power_dynamics_role), "
|
||||
"group_dynamics (string), "
|
||||
"key_observations (array de strings). "
|
||||
"Retorne APENAS o JSON valido. "
|
||||
"Transcricao: {transcription}"
|
||||
),
|
||||
"communication": (
|
||||
"Avalie a comunicacao dos participantes nesta reuniao e retorne um JSON com: "
|
||||
"overall_score (numero de 1 a 10), "
|
||||
"dimensions (objeto com clarity, active_listening, assertiveness, meeting_facilitation, "
|
||||
"cada um com score e notes), "
|
||||
"strengths (array de objetos com description e example), "
|
||||
"improvement_areas (array de objetos com description e suggestion), "
|
||||
"general_feedback (string). "
|
||||
"Retorne APENAS o JSON valido. "
|
||||
"Transcricao: {transcription}"
|
||||
),
|
||||
"summary": (
|
||||
"Gere um resumo executivo desta reuniao em JSON com: "
|
||||
"summary (string), "
|
||||
"key_points (array de strings), "
|
||||
"duration_minutes (number), "
|
||||
"total_participants (number). "
|
||||
"Retorne APENAS o JSON valido. "
|
||||
"Transcricao: {transcription}"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def run_analysis(analysis_type: str, transcription: dict) -> dict:
|
||||
"""Run AI analysis on transcription using Ollama"""
|
||||
transcription_text = json.dumps(transcription, ensure_ascii=False)
|
||||
prompt_template = PROMPTS.get(analysis_type, PROMPTS["summary"])
|
||||
prompt = prompt_template.replace("{transcription}", transcription_text[:8000])
|
||||
|
||||
logger.info(f"Running {analysis_type} analysis with Ollama model {settings.OLLAMA_MODEL}")
|
||||
|
||||
try:
|
||||
headers = {}
|
||||
if settings.OLLAMA_API_KEY:
|
||||
headers["X-API-Key"] = settings.OLLAMA_API_KEY
|
||||
async with httpx.AsyncClient(timeout=180.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.OLLAMA_URL}/api/generate",
|
||||
headers=headers,
|
||||
json={
|
||||
"model": settings.OLLAMA_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"format": "json",
|
||||
"options": {"temperature": 0.3},
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
response_text = result.get("response", "{}")
|
||||
try:
|
||||
return json.loads(response_text)
|
||||
except json.JSONDecodeError:
|
||||
json_match = re.search(r"\{.*\}", response_text, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
return json.loads(json_match.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return {"raw_response": response_text, "parse_error": "Could not parse JSON"}
|
||||
except httpx.ConnectError as e:
|
||||
logger.error(f"Cannot connect to Ollama at {settings.OLLAMA_URL}: {e}")
|
||||
return {"error": "Ollama unavailable", "analysis_type": analysis_type}
|
||||
except Exception as e:
|
||||
logger.error(f"Ollama analysis error for {analysis_type}: {e}")
|
||||
return {"error": str(e), "analysis_type": analysis_type}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import os
|
||||
import aiofiles
|
||||
from fastapi import UploadFile, HTTPException
|
||||
from app.config import settings
|
||||
|
||||
|
||||
async def save_audio_file(file: UploadFile, meeting_id: str) -> str:
|
||||
os.makedirs(settings.AUDIO_STORAGE_PATH, exist_ok=True)
|
||||
original_name = file.filename or "audio.wav"
|
||||
ext = os.path.splitext(original_name)[1].lower() or ".wav"
|
||||
|
||||
allowed_extensions = {".wav", ".mp3", ".m4a", ".ogg", ".flac", ".mp4", ".webm"}
|
||||
if ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported audio format '{ext}'. Supported: {', '.join(allowed_extensions)}",
|
||||
)
|
||||
|
||||
filename = f"{meeting_id}{ext}"
|
||||
file_path = os.path.join(settings.AUDIO_STORAGE_PATH, filename)
|
||||
max_size = settings.UPLOAD_MAX_SIZE_MB * 1024 * 1024
|
||||
total_size = 0
|
||||
|
||||
async with aiofiles.open(file_path, "wb") as f:
|
||||
while True:
|
||||
chunk = await file.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total_size += len(chunk)
|
||||
if total_size > max_size:
|
||||
await f.close()
|
||||
os.remove(file_path)
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File too large. Maximum allowed size is {settings.UPLOAD_MAX_SIZE_MB}MB",
|
||||
)
|
||||
await f.write(chunk)
|
||||
|
||||
return file_path
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
from fastapi import WebSocket
|
||||
from typing import Dict, List
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
def __init__(self):
|
||||
self.active_connections: Dict[str, List[WebSocket]] = {}
|
||||
|
||||
async def connect(self, websocket: WebSocket, meeting_id: str):
|
||||
await websocket.accept()
|
||||
if meeting_id not in self.active_connections:
|
||||
self.active_connections[meeting_id] = []
|
||||
self.active_connections[meeting_id].append(websocket)
|
||||
logger.info(f"WebSocket connected: meeting={meeting_id}, total={len(self.active_connections[meeting_id])}")
|
||||
|
||||
def disconnect(self, websocket: WebSocket, meeting_id: str):
|
||||
if meeting_id in self.active_connections:
|
||||
try:
|
||||
self.active_connections[meeting_id].remove(websocket)
|
||||
except ValueError:
|
||||
pass
|
||||
logger.info(f"WebSocket disconnected: meeting={meeting_id}")
|
||||
|
||||
async def send_message(self, meeting_id: str, message: dict):
|
||||
if meeting_id not in self.active_connections:
|
||||
return
|
||||
disconnected = []
|
||||
for ws in self.active_connections[meeting_id]:
|
||||
try:
|
||||
await ws.send_text(json.dumps(message, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
logger.warning(f"WebSocket send error for meeting {meeting_id}: {e}")
|
||||
disconnected.append(ws)
|
||||
for ws in disconnected:
|
||||
self.active_connections[meeting_id].remove(ws)
|
||||
|
||||
async def broadcast_status(self, meeting_id: str, status: str, data: dict = None):
|
||||
message = {
|
||||
"type": "status_update",
|
||||
"meeting_id": meeting_id,
|
||||
"status": status,
|
||||
"data": data or {},
|
||||
}
|
||||
await self.send_message(meeting_id, message)
|
||||
|
||||
|
||||
manager = WebSocketManager()
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import httpx
|
||||
import logging
|
||||
import os
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MIME_TYPES = {
|
||||
".webm": "audio/webm",
|
||||
".wav": "audio/wav",
|
||||
".mp3": "audio/mpeg",
|
||||
".m4a": "audio/mp4",
|
||||
".ogg": "audio/ogg",
|
||||
".flac": "audio/flac",
|
||||
}
|
||||
|
||||
async def transcribe_audio(audio_path: str) -> dict:
|
||||
"""
|
||||
Send audio to Whisper Diarization API.
|
||||
1) Tries POST /api/v2/transcribe (diarization — field: audio) -> returns segments with speaker labels
|
||||
2) Falls back to POST /transcribe-segments (no diarization) -> assigns SPEAKER_01 to all
|
||||
"""
|
||||
logger.info(f"Sending audio to Whisper server: {audio_path}")
|
||||
try:
|
||||
with open(audio_path, "rb") as f:
|
||||
content = f.read()
|
||||
filename = os.path.basename(audio_path)
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
mime_type = MIME_TYPES.get(ext, "audio/webm")
|
||||
|
||||
async with httpx.AsyncClient(timeout=600.0) as http:
|
||||
# 1) Try /api/v2/transcribe with diarization (field: audio)
|
||||
try:
|
||||
files = {"audio": (filename, content, mime_type)}
|
||||
data = {"language": "pt"}
|
||||
resp = await http.post(
|
||||
f"{settings.WHISPER_SERVER_URL}/api/v2/transcribe",
|
||||
files=files, data=data, timeout=600.0,
|
||||
)
|
||||
if resp.status_code not in (404, 422, 500):
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
logger.info(f"Diarized transcription completed for {audio_path} "
|
||||
f"({result.get('speakers_count', '?')} speakers, "
|
||||
f"method: {result.get('diarization_method', '?')})")
|
||||
return result
|
||||
logger.warning(f"/api/v2/transcribe returned {resp.status_code}, falling back...")
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code not in (404, 422, 500):
|
||||
raise
|
||||
logger.warning(f"/api/v2/transcribe error {e.response.status_code}, falling back...")
|
||||
except Exception as e:
|
||||
logger.warning(f"/api/v2/transcribe exception: {e}, falling back...")
|
||||
|
||||
# 2) Fallback: /transcribe-segments (no speaker diarization)
|
||||
files = {"file": (filename, content, mime_type)}
|
||||
data = {"language": "pt"}
|
||||
resp = await http.post(
|
||||
f"{settings.WHISPER_SERVER_URL}/transcribe-segments",
|
||||
files=files, data=data, timeout=600.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
logger.info(f"Transcription via fallback /transcribe-segments completed for {audio_path}")
|
||||
|
||||
segments = result.get("segments", [])
|
||||
normalized = [
|
||||
{
|
||||
"start": seg.get("start", 0.0),
|
||||
"end": seg.get("end", 0.0),
|
||||
"speaker": "SPEAKER_01",
|
||||
"text": seg.get("text", "").strip(),
|
||||
}
|
||||
for seg in segments
|
||||
]
|
||||
return {
|
||||
"segments": normalized,
|
||||
"speakers_count": 1,
|
||||
"audio_duration": segments[-1]["end"] if segments else 0.0,
|
||||
"language": result.get("language", "pt"),
|
||||
"full_text": result.get("text", ""),
|
||||
"diarization_available": False,
|
||||
"diarization_method": "none",
|
||||
}
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
logger.error(f"Cannot connect to Whisper server at {settings.WHISPER_SERVER_URL}: {e}")
|
||||
raise Exception(f"Whisper server unavailable: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription error: {e}")
|
||||
raise
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
import logging
|
||||
import asyncio
|
||||
from celery import Celery
|
||||
from app.config import settings
|
||||
from app.database import SessionLocal
|
||||
from app.models.meeting import Meeting, MeetingStatus
|
||||
from app.models.analysis import Analysis, AnalysisType
|
||||
from app.services.whisper_service import transcribe_audio
|
||||
from app.services.ollama_service import run_analysis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
celery_app = Celery(
|
||||
"meeting_assistant",
|
||||
broker=settings.REDIS_URL,
|
||||
backend=settings.REDIS_URL,
|
||||
)
|
||||
|
||||
celery_app.conf.update(
|
||||
task_serializer="json",
|
||||
accept_content=["json"],
|
||||
result_serializer="json",
|
||||
timezone="UTC",
|
||||
enable_utc=True,
|
||||
task_acks_late=True,
|
||||
worker_prefetch_multiplier=1,
|
||||
)
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
@celery_app.task(bind=True, max_retries=3, default_retry_delay=60)
|
||||
def process_meeting(self, meeting_id: str):
|
||||
"""
|
||||
Full meeting processing pipeline:
|
||||
1. Save audio (already done at upload)
|
||||
2. Transcribe via Whisper
|
||||
3. Analyze via Ollama (minutes, sentiment, psychological, communication, summary)
|
||||
4. Update status to completed
|
||||
"""
|
||||
db = SessionLocal()
|
||||
meeting = None
|
||||
try:
|
||||
meeting = db.query(Meeting).filter(Meeting.id == meeting_id).first()
|
||||
if not meeting:
|
||||
logger.error(f"Meeting {meeting_id} not found in DB")
|
||||
return
|
||||
|
||||
# Step 1 - Mark processing
|
||||
meeting.status = MeetingStatus.processing
|
||||
db.commit()
|
||||
logger.info(f"Processing meeting {meeting_id}")
|
||||
|
||||
# Step 2 - Transcription
|
||||
meeting.status = MeetingStatus.transcribing
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
transcription = run_async(transcribe_audio(meeting.audio_path))
|
||||
meeting.transcription = transcription
|
||||
db.commit()
|
||||
logger.info(f"Transcription done for meeting {meeting_id}")
|
||||
|
||||
# Save participants from transcription
|
||||
try:
|
||||
from app.models.participant import MeetingParticipant
|
||||
speaker_labels = {seg.get("speaker") for seg in transcription.get("segments", []) if seg.get("speaker")}
|
||||
for label in speaker_labels:
|
||||
existing = db.query(MeetingParticipant).filter(
|
||||
MeetingParticipant.meeting_id == meeting.id,
|
||||
MeetingParticipant.speaker_label == label,
|
||||
).first()
|
||||
if not existing:
|
||||
db.add(MeetingParticipant(
|
||||
meeting_id=meeting.id,
|
||||
speaker_label=label,
|
||||
identified_name=None if label.startswith("SPEAKER_") else label,
|
||||
))
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save participants: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription failed for {meeting_id}: {e}")
|
||||
meeting.status = MeetingStatus.error
|
||||
db.commit()
|
||||
return
|
||||
|
||||
# Step 3 - AI Analyses
|
||||
meeting.status = MeetingStatus.analyzing
|
||||
db.commit()
|
||||
|
||||
analysis_types = ["minutes", "sentiment", "psychological", "communication", "summary"]
|
||||
for analysis_type in analysis_types:
|
||||
try:
|
||||
content = run_async(run_analysis(analysis_type, transcription))
|
||||
existing = (
|
||||
db.query(Analysis)
|
||||
.filter(Analysis.meeting_id == meeting_id, Analysis.type == AnalysisType(analysis_type))
|
||||
.first()
|
||||
)
|
||||
if existing:
|
||||
existing.content = content
|
||||
existing.model_used = settings.OLLAMA_MODEL
|
||||
else:
|
||||
analysis = Analysis(
|
||||
meeting_id=meeting.id,
|
||||
type=AnalysisType(analysis_type),
|
||||
content=content,
|
||||
model_used=settings.OLLAMA_MODEL,
|
||||
)
|
||||
db.add(analysis)
|
||||
db.commit()
|
||||
logger.info(f"Analysis '{analysis_type}' done for meeting {meeting_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Analysis '{analysis_type}' failed for {meeting_id}: {e}")
|
||||
|
||||
# Step 4 - Mark completed
|
||||
meeting.status = MeetingStatus.completed
|
||||
db.commit()
|
||||
logger.info(f"Meeting {meeting_id} processing COMPLETED")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error processing meeting {meeting_id}: {e}")
|
||||
if meeting:
|
||||
meeting.status = MeetingStatus.error
|
||||
db.commit()
|
||||
raise self.retry(exc=e, countdown=60)
|
||||
finally:
|
||||
db.close()
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import jwt
|
||||
from jwt import PyJWKClient
|
||||
from typing import Optional
|
||||
|
||||
JWKS_URI = "https://auth.asaptelecom.com.br/realms/one/protocol/openid-connect/certs"
|
||||
ISSUER = "https://auth.asaptelecom.com.br/realms/one"
|
||||
AUDIENCE = "maia-app"
|
||||
|
||||
jwks_client = PyJWKClient(JWKS_URI, cache_keys=True)
|
||||
|
||||
|
||||
def validate_keycloak_token(token: str) -> Optional[dict]:
|
||||
"""
|
||||
Validate a Keycloak RS256 JWT token.
|
||||
Returns decoded payload or None if invalid.
|
||||
"""
|
||||
try:
|
||||
signing_key = jwks_client.get_signing_key_from_jwt(token)
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
signing_key.key,
|
||||
algorithms=["RS256"],
|
||||
issuer=ISSUER,
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
return payload
|
||||
except Exception as e:
|
||||
print(f"[keycloak_auth] Token validation failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def has_role(payload: dict, role: str) -> bool:
|
||||
"""Check if role is present in realm_access or resource_access."""
|
||||
realm_roles = payload.get("realm_access", {}).get("roles", [])
|
||||
if role in realm_roles:
|
||||
return True
|
||||
for client_roles in payload.get("resource_access", {}).values():
|
||||
if role in client_roles.get("roles", []):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_user_info(payload: dict) -> dict:
|
||||
"""Extract user info from KC token."""
|
||||
return {
|
||||
"sub": payload.get("sub"),
|
||||
"email": payload.get("email", ""),
|
||||
"name": (payload.get("name") or
|
||||
f"{payload.get('given_name', '')} {payload.get('family_name', '')}".strip()),
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import jwt
|
||||
from datetime import datetime, timedelta
|
||||
import bcrypt
|
||||
from typing import Optional
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
try:
|
||||
return bcrypt.checkpw(
|
||||
plain_password.encode("utf-8"),
|
||||
hashed_password.encode("utf-8"),
|
||||
)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def create_access_token(user_id: str) -> str:
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"exp": datetime.utcnow() + timedelta(minutes=settings.JWT_EXPIRE_MINUTES),
|
||||
"iat": datetime.utcnow(),
|
||||
"type": "access",
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def create_refresh_token(user_id: str) -> str:
|
||||
payload = {
|
||||
"sub": user_id,
|
||||
"exp": datetime.utcnow() + timedelta(days=settings.JWT_REFRESH_EXPIRE_DAYS),
|
||||
"iat": datetime.utcnow(),
|
||||
"type": "refresh",
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET, algorithm="HS256")
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
return jwt.decode(token, settings.JWT_SECRET, algorithms=["HS256"])
|
||||
except jwt.ExpiredSignatureError:
|
||||
return None
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: meetingassistant
|
||||
POSTGRES_PASSWORD: meetingassistant
|
||||
POSTGRES_DB: meeting_assistant
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U meetingassistant"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
api:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8000"
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://meetingassistant:meetingassistant@postgres:5432/meeting_assistant
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
volumes:
|
||||
- audio_storage:/opt/Backend/meeting-assistant-api/storage/audio
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
worker:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://meetingassistant:meetingassistant@postgres:5432/meeting_assistant
|
||||
- REDIS_URL=redis://redis:6379/0
|
||||
volumes:
|
||||
- audio_storage:/opt/Backend/meeting-assistant-api/storage/audio
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
command: celery -A app.tasks.processing.celery_app worker --loglevel=info --concurrency=2
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
audio_storage:
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
alembic==1.12.1
|
||||
psycopg2-binary==2.9.9
|
||||
redis==5.0.1
|
||||
celery==5.3.4
|
||||
PyJWT==2.8.0
|
||||
python-multipart==0.0.6
|
||||
httpx==0.25.2
|
||||
pydantic==2.5.2
|
||||
pydantic-settings==2.1.0
|
||||
aiofiles==23.2.1
|
||||
python-dotenv==1.0.0
|
||||
websockets==12.0
|
||||
cryptography
|
||||
|
|
@ -0,0 +1 @@
|
|||
# __init__.py
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
"""Database initialization wrapper."""
|
||||
import os
|
||||
import logging
|
||||
from services.enrollment import init_db as _init_enrollment_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def init_db():
|
||||
"""Initialize all database tables."""
|
||||
_init_enrollment_db()
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"""
|
||||
Whisper Diarization API — Enhancement Server
|
||||
Adds /api/v2 endpoints (diarization + voice enrollment) to the existing Whisper server.
|
||||
Transparently proxies existing endpoints to the original Whisper server.
|
||||
|
||||
Original Whisper server: http://10.100.16.13:5003
|
||||
This server runs on: port 5060
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from routers.proxy import router as proxy_router
|
||||
from routers.v2_transcribe import router as v2_transcribe_router
|
||||
from routers.v2_speakers import router as v2_speakers_router
|
||||
from db.database import init_db
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WHISPER_ORIGIN = os.getenv("WHISPER_ORIGIN", "http://10.100.16.13:5003")
|
||||
|
||||
app = FastAPI(
|
||||
title="Whisper Diarization Enhancement Server",
|
||||
description=(
|
||||
"Adds Speaker Diarization + Voice Enrollment on top of the existing Whisper.cpp GPU server. "
|
||||
"Existing endpoints (/transcribe, /transcribe-segments, /whisper, /transcribe-text, /health) "
|
||||
"are transparently proxied to the original server."
|
||||
),
|
||||
version="2.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
logger.info("Whisper Diarization Enhancement Server starting...")
|
||||
init_db()
|
||||
logger.info(f"Proxying existing endpoints to: {WHISPER_ORIGIN}")
|
||||
logger.info("New endpoints available under /api/v2/")
|
||||
logger.info("Server ready!")
|
||||
|
||||
# Proxy existing Whisper endpoints (NEVER MODIFY THESE)
|
||||
app.include_router(proxy_router, tags=["proxy — original whisper endpoints"])
|
||||
|
||||
# New v2 endpoints
|
||||
app.include_router(v2_transcribe_router, prefix="/api/v2", tags=["v2 — diarization"])
|
||||
app.include_router(v2_speakers_router, prefix="/api/v2", tags=["v2 — voice enrollment"])
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request, exc):
|
||||
logger.error(f"Unhandled exception: {exc}", exc_info=True)
|
||||
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# Whisper Diarization Enhancement Server
|
||||
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
httpx==0.25.2
|
||||
python-multipart==0.0.6
|
||||
aiofiles==23.2.1
|
||||
scipy>=1.10.0
|
||||
numpy>=1.24.0
|
||||
librosa>=0.10.0
|
||||
soundfile>=0.12.1
|
||||
|
||||
# Optional — heavier ML models (installed separately if available)
|
||||
# pyannote.audio>=3.1.0
|
||||
# speechbrain>=0.5.16
|
||||
# torch>=2.0.0
|
||||
|
|
@ -0,0 +1 @@
|
|||
# __init__.py files
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"""
|
||||
Proxy router — transparently forwards existing Whisper server endpoints.
|
||||
NEVER modify these routes or their behavior.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
from fastapi import APIRouter, Request, UploadFile, File, Form
|
||||
from fastapi.responses import Response
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
WHISPER_ORIGIN = os.getenv("WHISPER_ORIGIN", "http://10.100.16.13:5003")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def _proxy_get(path: str) -> Response:
|
||||
"""Forward a GET request to the original Whisper server."""
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(f"{WHISPER_ORIGIN}{path}")
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
media_type=resp.headers.get("content-type", "application/json"),
|
||||
)
|
||||
|
||||
|
||||
async def _proxy_upload(path: str, file_bytes: bytes, filename: str, extra_fields: dict) -> Response:
|
||||
"""Forward a multipart file upload to the original Whisper server."""
|
||||
async with httpx.AsyncClient(timeout=600.0) as client:
|
||||
files = {"file": (filename, file_bytes, "application/octet-stream")}
|
||||
data = extra_fields
|
||||
resp = await client.post(f"{WHISPER_ORIGIN}{path}", files=files, data=data)
|
||||
return Response(
|
||||
content=resp.content,
|
||||
status_code=resp.status_code,
|
||||
media_type=resp.headers.get("content-type", "application/json"),
|
||||
)
|
||||
|
||||
|
||||
# ── Proxy: GET /health ───────────────────────────────────────────────────────
|
||||
@router.get("/health", summary="Health — proxied from original Whisper server")
|
||||
async def health():
|
||||
return await _proxy_get("/health")
|
||||
|
||||
|
||||
# ── Proxy: POST /transcribe ──────────────────────────────────────────────────
|
||||
@router.post("/transcribe", summary="Transcribe — proxied from original Whisper server")
|
||||
async def transcribe(
|
||||
file: UploadFile = File(...),
|
||||
language: str = Form("pt"),
|
||||
):
|
||||
file_bytes = await file.read()
|
||||
return await _proxy_upload("/transcribe", file_bytes, file.filename or "audio.wav", {"language": language})
|
||||
|
||||
|
||||
# ── Proxy: POST /transcribe-segments ────────────────────────────────────────
|
||||
@router.post("/transcribe-segments", summary="Transcribe Segments — proxied from original Whisper server")
|
||||
async def transcribe_segments(
|
||||
file: UploadFile = File(...),
|
||||
language: str = Form("pt"),
|
||||
):
|
||||
file_bytes = await file.read()
|
||||
return await _proxy_upload("/transcribe-segments", file_bytes, file.filename or "audio.wav", {"language": language})
|
||||
|
||||
|
||||
# ── Proxy: POST /transcribe-text ────────────────────────────────────────────
|
||||
@router.post("/transcribe-text", summary="Transcribe Text — proxied from original Whisper server")
|
||||
async def transcribe_text(
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
file_bytes = await file.read()
|
||||
return await _proxy_upload("/transcribe-text", file_bytes, file.filename or "audio.wav", {})
|
||||
|
||||
|
||||
# ── Proxy: POST /whisper ─────────────────────────────────────────────────────
|
||||
@router.post("/whisper", summary="Whisper Compat — proxied from original Whisper server")
|
||||
async def whisper_compat(
|
||||
file: UploadFile = File(...),
|
||||
):
|
||||
file_bytes = await file.read()
|
||||
return await _proxy_upload("/whisper", file_bytes, file.filename or "audio.wav", {})
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
"""
|
||||
Voice Enrollment & Speaker Identification endpoints.
|
||||
|
||||
POST /api/v2/enroll — enroll a new voice profile
|
||||
POST /api/v2/enroll/from-meeting — enroll from meeting segments
|
||||
GET /api/v2/speakers — list all profiles
|
||||
GET /api/v2/speakers/{id} — get profile details
|
||||
PUT /api/v2/speakers/{id} — update profile
|
||||
DELETE /api/v2/speakers/{id} — delete profile
|
||||
POST /api/v2/identify — identify a speaker from audio
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import tempfile
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.enrollment import (
|
||||
create_profile,
|
||||
get_profile,
|
||||
list_profiles,
|
||||
update_profile,
|
||||
delete_profile,
|
||||
add_embedding,
|
||||
identify_speaker,
|
||||
)
|
||||
from services.diarization import extract_embedding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||
class VoiceProfileResponse(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
email: Optional[str] = None
|
||||
metadata: Optional[str] = None
|
||||
embeddings_count: int
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
metadata: Optional[str] = None
|
||||
|
||||
|
||||
class IdentifyResponse(BaseModel):
|
||||
matched: bool
|
||||
speaker_id: Optional[str] = None
|
||||
speaker_name: Optional[str] = None
|
||||
confidence: float
|
||||
threshold: float
|
||||
|
||||
|
||||
# ── Helper ────────────────────────────────────────────────────────────────────
|
||||
async def _save_and_embed(file_bytes: bytes, suffix: str) -> object:
|
||||
"""Save audio bytes to a temp file and extract embedding."""
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(file_bytes)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
embedding = extract_embedding(tmp_path)
|
||||
return embedding
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── POST /enroll ──────────────────────────────────────────────────────────────
|
||||
@router.post(
|
||||
"/enroll",
|
||||
response_model=VoiceProfileResponse,
|
||||
status_code=201,
|
||||
summary="Enroll a new voice profile",
|
||||
description=(
|
||||
"Submit an audio file (minimum 30 seconds recommended) to enroll a new speaker. "
|
||||
"Extracts a vocal embedding and saves the profile to the local SQLite database."
|
||||
),
|
||||
)
|
||||
async def enroll(
|
||||
file: UploadFile = File(..., description="Audio file with the speaker's voice (min 30s recommended)"),
|
||||
name: str = Form(..., description="Speaker full name"),
|
||||
email: Optional[str] = Form(None, description="Speaker email (optional)"),
|
||||
metadata: Optional[str] = Form(None, description="Extra JSON metadata (optional)"),
|
||||
):
|
||||
audio_bytes = await file.read()
|
||||
suffix = os.path.splitext(file.filename or ".wav")[-1].lower() or ".wav"
|
||||
|
||||
embedding = await _save_and_embed(audio_bytes, suffix)
|
||||
|
||||
profile = create_profile(name=name, email=email, metadata=metadata)
|
||||
add_embedding(profile["id"], embedding)
|
||||
|
||||
profile["embeddings_count"] = 1
|
||||
return VoiceProfileResponse(**profile)
|
||||
|
||||
|
||||
# ── POST /enroll/from-meeting ─────────────────────────────────────────────────
|
||||
@router.post(
|
||||
"/enroll/from-meeting",
|
||||
response_model=VoiceProfileResponse,
|
||||
status_code=201,
|
||||
summary="Enroll speaker from meeting audio segments",
|
||||
description=(
|
||||
"Extract voice embedding from specific speaker segments of an already processed meeting. "
|
||||
"Creates a new profile or adds embeddings to an existing one (matched by name)."
|
||||
),
|
||||
)
|
||||
async def enroll_from_meeting(
|
||||
meeting_segments: str = Form(
|
||||
...,
|
||||
description='JSON array of {speaker_label, audio_file_path}: [{"speaker_label":"SPEAKER_01","audio_file_path":"/path/audio.wav"}]',
|
||||
),
|
||||
speaker_name: str = Form(..., description="Name to assign to this speaker"),
|
||||
speaker_label: str = Form(..., description="Speaker label to extract (e.g. SPEAKER_01)"),
|
||||
existing_profile_id: Optional[str] = Form(None, description="Existing profile ID to add embeddings to (optional)"),
|
||||
):
|
||||
try:
|
||||
segments = json.loads(meeting_segments)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
raise HTTPException(status_code=422, detail=f"Invalid meeting_segments JSON: {e}")
|
||||
|
||||
# Filter segments matching the requested speaker
|
||||
target_segments = [s for s in segments if s.get("speaker_label") == speaker_label]
|
||||
if not target_segments:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"No segments found for speaker_label='{speaker_label}'",
|
||||
)
|
||||
|
||||
# Get or create profile
|
||||
if existing_profile_id:
|
||||
profile = get_profile(existing_profile_id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail=f"Profile '{existing_profile_id}' not found")
|
||||
else:
|
||||
profile = create_profile(name=speaker_name, email=None, metadata=None)
|
||||
|
||||
# Extract embeddings for each segment
|
||||
emb_count = 0
|
||||
for seg in target_segments:
|
||||
audio_path = seg.get("audio_file_path", "")
|
||||
if not os.path.isfile(audio_path):
|
||||
logger.warning(f"Audio file not found: {audio_path}")
|
||||
continue
|
||||
try:
|
||||
embedding = extract_embedding(audio_path)
|
||||
add_embedding(profile["id"], embedding)
|
||||
emb_count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract embedding from {audio_path}: {e}")
|
||||
|
||||
if emb_count == 0:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="No valid audio segments could be processed. Check that audio_file_path values exist on the server.",
|
||||
)
|
||||
|
||||
return VoiceProfileResponse(**get_profile(profile["id"]))
|
||||
|
||||
|
||||
# ── GET /speakers ─────────────────────────────────────────────────────────────
|
||||
@router.get(
|
||||
"/speakers",
|
||||
response_model=List[VoiceProfileResponse],
|
||||
summary="List all enrolled voice profiles",
|
||||
)
|
||||
async def list_speakers():
|
||||
return [VoiceProfileResponse(**p) for p in list_profiles()]
|
||||
|
||||
|
||||
# ── GET /speakers/{id} ────────────────────────────────────────────────────────
|
||||
@router.get(
|
||||
"/speakers/{profile_id}",
|
||||
response_model=VoiceProfileResponse,
|
||||
summary="Get a voice profile by ID",
|
||||
)
|
||||
async def get_speaker(profile_id: str):
|
||||
profile = get_profile(profile_id)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="Voice profile not found")
|
||||
return VoiceProfileResponse(**profile)
|
||||
|
||||
|
||||
# ── PUT /speakers/{id} ────────────────────────────────────────────────────────
|
||||
@router.put(
|
||||
"/speakers/{profile_id}",
|
||||
response_model=VoiceProfileResponse,
|
||||
summary="Update a voice profile (name, email, metadata — all optional)",
|
||||
)
|
||||
async def update_speaker(profile_id: str, data: UpdateProfileRequest):
|
||||
profile = update_profile(
|
||||
profile_id,
|
||||
name=data.name,
|
||||
email=data.email,
|
||||
metadata=data.metadata,
|
||||
)
|
||||
if profile is None:
|
||||
raise HTTPException(status_code=404, detail="Voice profile not found")
|
||||
return VoiceProfileResponse(**profile)
|
||||
|
||||
|
||||
# ── DELETE /speakers/{id} ─────────────────────────────────────────────────────
|
||||
@router.delete(
|
||||
"/speakers/{profile_id}",
|
||||
status_code=204,
|
||||
summary="Delete a voice profile and all its embeddings",
|
||||
)
|
||||
async def delete_speaker(profile_id: str):
|
||||
if not delete_profile(profile_id):
|
||||
raise HTTPException(status_code=404, detail="Voice profile not found")
|
||||
|
||||
|
||||
# ── POST /identify ────────────────────────────────────────────────────────────
|
||||
@router.post(
|
||||
"/identify",
|
||||
response_model=IdentifyResponse,
|
||||
summary="Identify the speaker in an audio clip",
|
||||
description=(
|
||||
"Extracts a vocal embedding from the submitted audio and compares it against all "
|
||||
"enrolled profiles. Returns the best match if confidence >= threshold."
|
||||
),
|
||||
)
|
||||
async def identify(
|
||||
file: UploadFile = File(..., description="Short audio clip to identify the speaker"),
|
||||
threshold: Optional[float] = Form(None, description="Confidence threshold (default: 0.75)"),
|
||||
):
|
||||
audio_bytes = await file.read()
|
||||
suffix = os.path.splitext(file.filename or ".wav")[-1].lower() or ".wav"
|
||||
|
||||
embedding = await _save_and_embed(audio_bytes, suffix)
|
||||
result = identify_speaker(embedding, threshold=threshold)
|
||||
return IdentifyResponse(**result)
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
"""
|
||||
POST /api/v2/transcribe — Transcription with speaker diarization.
|
||||
|
||||
Calls the original Whisper server for transcription (with timestamps),
|
||||
then runs speaker diarization and aligns the results.
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import tempfile
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.diarization import diarize, align_transcription_with_diarization, extract_embedding
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
WHISPER_ORIGIN = os.getenv("WHISPER_ORIGIN", "http://10.100.16.13:5003")
|
||||
|
||||
|
||||
# ── Response model ────────────────────────────────────────────────────────────
|
||||
class TranscriptionSegment(BaseModel):
|
||||
start: float
|
||||
end: float
|
||||
speaker: str
|
||||
text: str
|
||||
|
||||
|
||||
class DiarizedTranscriptionResponse(BaseModel):
|
||||
segments: List[TranscriptionSegment]
|
||||
speakers_count: int
|
||||
audio_duration: float
|
||||
language: str
|
||||
full_text: str
|
||||
diarization_method: str
|
||||
|
||||
|
||||
# ── Endpoint ──────────────────────────────────────────────────────────────────
|
||||
@router.post(
|
||||
"/transcribe",
|
||||
response_model=DiarizedTranscriptionResponse,
|
||||
summary="Transcription + Speaker Diarization",
|
||||
description=(
|
||||
"Transcribes the audio using the Whisper.cpp GPU server and adds speaker diarization. "
|
||||
"Returns segments labelled by speaker (SPEAKER_01, SPEAKER_02, …)."
|
||||
),
|
||||
)
|
||||
async def v2_transcribe(
|
||||
audio: UploadFile = File(..., description="Audio file (WAV, MP3, M4A, OGG, FLAC)"),
|
||||
language: str = Form("pt", description="Language code (default: pt)"),
|
||||
num_speakers: Optional[int] = Form(None, description="Expected number of speakers (optional, auto-detected if omitted)"),
|
||||
):
|
||||
audio_bytes = await audio.read()
|
||||
filename = audio.filename or "audio.wav"
|
||||
|
||||
# Step 1 — call original Whisper server for timestamps
|
||||
transcription_segments = await _call_whisper_segments(audio_bytes, filename, language)
|
||||
|
||||
# Step 2 — save to temp file for diarization
|
||||
suffix = os.path.splitext(filename)[-1].lower() or ".wav"
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
tmp.write(audio_bytes)
|
||||
tmp_path = tmp.name
|
||||
|
||||
try:
|
||||
# Step 3 — diarize
|
||||
diarization_segs, method = diarize(tmp_path, num_speakers=num_speakers)
|
||||
|
||||
# Step 4 — align
|
||||
aligned = align_transcription_with_diarization(transcription_segments, diarization_segs)
|
||||
|
||||
# Auto-identify speakers against enrolled profiles
|
||||
try:
|
||||
import librosa
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
from services.enrollment import identify_speaker
|
||||
|
||||
y, sr = librosa.load(tmp_path, sr=16000, mono=True)
|
||||
speaker_labels = sorted({s["speaker"] for s in aligned})
|
||||
|
||||
for speaker_label in speaker_labels:
|
||||
chunks = []
|
||||
for dseg in diarization_segs:
|
||||
if dseg["speaker"] == speaker_label:
|
||||
s_i = int(dseg["start"] * sr)
|
||||
e_i = int(dseg["end"] * sr)
|
||||
if e_i > s_i + int(0.5 * sr):
|
||||
chunks.append(y[s_i:min(e_i, len(y))])
|
||||
|
||||
if not chunks:
|
||||
continue
|
||||
|
||||
speaker_audio = np.concatenate(chunks)
|
||||
if len(speaker_audio) < sr * 3:
|
||||
continue
|
||||
|
||||
spk_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as spk_tmp:
|
||||
sf.write(spk_tmp.name, speaker_audio, sr)
|
||||
spk_path = spk_tmp.name
|
||||
|
||||
embedding = extract_embedding(spk_path)
|
||||
id_result = identify_speaker(embedding)
|
||||
|
||||
if id_result["matched"]:
|
||||
identified_name = id_result["speaker_name"]
|
||||
logger.info(f"Auto-identified {speaker_label} as '{identified_name}' (conf={id_result['confidence']:.2f})")
|
||||
for seg in aligned:
|
||||
if seg["speaker"] == speaker_label:
|
||||
seg["speaker"] = identified_name
|
||||
finally:
|
||||
if spk_path:
|
||||
try:
|
||||
os.unlink(spk_path)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as auto_id_err:
|
||||
logger.warning(f"Auto-identification failed (non-fatal): {auto_id_err}")
|
||||
|
||||
# Step 5 — compute metadata
|
||||
speakers = sorted({s["speaker"] for s in aligned})
|
||||
full_text = " ".join(s["text"] for s in aligned if s.get("text"))
|
||||
|
||||
# Audio duration from diarization or transcription
|
||||
if diarization_segs:
|
||||
audio_duration = max(s["end"] for s in diarization_segs)
|
||||
elif transcription_segments:
|
||||
audio_duration = max(s.get("end", 0) for s in transcription_segments)
|
||||
else:
|
||||
audio_duration = 0.0
|
||||
|
||||
return DiarizedTranscriptionResponse(
|
||||
segments=[TranscriptionSegment(**s) for s in aligned],
|
||||
speakers_count=len(speakers),
|
||||
audio_duration=round(audio_duration, 2),
|
||||
language=language,
|
||||
full_text=full_text.strip(),
|
||||
diarization_method=method,
|
||||
)
|
||||
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ── Helper: call Whisper for transcription with timestamps ────────────────────
|
||||
async def _call_whisper_segments(audio_bytes: bytes, filename: str, language: str) -> list:
|
||||
"""
|
||||
Calls the original Whisper server at /transcribe-segments and returns
|
||||
the segments list [{start, end, text}, ...].
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=600.0) as client:
|
||||
files = {"file": (filename, audio_bytes, "application/octet-stream")}
|
||||
data = {"language": language}
|
||||
resp = await client.post(
|
||||
f"{WHISPER_ORIGIN}/transcribe-segments",
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
result = resp.json()
|
||||
|
||||
# Expected: {"text": "...", "language": "...", "segments": [...]}
|
||||
segments = result.get("segments", [])
|
||||
if not segments and result.get("text"):
|
||||
# Fallback: no segments, create a single segment
|
||||
segments = [{"start": 0.0, "end": 0.0, "text": result["text"]}]
|
||||
return segments
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
logger.error(f"Cannot connect to Whisper server: {e}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"Whisper server unavailable: {str(e)}",
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Whisper server error {e.response.status_code}: {e.response.text}")
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f"Whisper server returned {e.response.status_code}",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Transcription failed: {str(e)}")
|
||||
|
|
@ -0,0 +1 @@
|
|||
# __init__.py
|
||||
|
|
@ -0,0 +1,490 @@
|
|||
"""
|
||||
Diarization service — speaker identification pipeline.
|
||||
|
||||
Priority order:
|
||||
1. pyannote.audio 3.1 — best quality, requires HuggingFace token with model access
|
||||
2. SpeechBrain ECAPA-TDNN — good quality (~85%), no license needed, GPU-accelerated
|
||||
3. Energy + MFCC fallback — basic quality (~60%), pure CPU, always available
|
||||
|
||||
Speaker embeddings for enrollment:
|
||||
Same priority: pyannote → SpeechBrain → MFCC
|
||||
"""
|
||||
import os
|
||||
import io
|
||||
import logging
|
||||
import tempfile
|
||||
import numpy as np
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Pyannote (primary) ────────────────────────────────────────────────────────
|
||||
_pyannote_pipeline = None
|
||||
_pyannote_tried = False
|
||||
HF_TOKEN = os.getenv("HF_TOKEN", "hf_placeholder_token")
|
||||
|
||||
# ── SpeechBrain (secondary) ───────────────────────────────────────────────────
|
||||
_speechbrain_model = None
|
||||
_speechbrain_tried = False
|
||||
SPEECHBRAIN_CACHE = os.getenv("SPEECHBRAIN_CACHE", "/tmp/speechbrain_ecapa")
|
||||
|
||||
|
||||
def _try_load_pyannote():
|
||||
global _pyannote_pipeline, _pyannote_tried
|
||||
if _pyannote_tried:
|
||||
return _pyannote_pipeline
|
||||
_pyannote_tried = True
|
||||
try:
|
||||
from pyannote.audio import Pipeline
|
||||
logger.info("Loading pyannote speaker-diarization-3.1 ...")
|
||||
pipeline = Pipeline.from_pretrained(
|
||||
"pyannote/speaker-diarization-3.1",
|
||||
use_auth_token=HF_TOKEN,
|
||||
)
|
||||
import torch
|
||||
if torch.cuda.is_available():
|
||||
pipeline = pipeline.to(torch.device("cuda"))
|
||||
_pyannote_pipeline = pipeline
|
||||
logger.info("pyannote pipeline loaded successfully.")
|
||||
except Exception as e:
|
||||
logger.warning(f"pyannote unavailable ({e}), will use energy-based fallback.")
|
||||
_pyannote_pipeline = None
|
||||
return _pyannote_pipeline
|
||||
|
||||
|
||||
# ── pyannote embedding (for enrollment) ──────────────────────────────────────
|
||||
_embedding_model = None
|
||||
_embedding_tried = False
|
||||
|
||||
|
||||
def _try_load_speechbrain():
|
||||
global _speechbrain_model, _speechbrain_tried
|
||||
if _speechbrain_tried:
|
||||
return _speechbrain_model
|
||||
_speechbrain_tried = True
|
||||
try:
|
||||
import torch
|
||||
from speechbrain.inference.speaker import EncoderClassifier
|
||||
logger.info("Loading SpeechBrain ECAPA-TDNN model...")
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
classifier = EncoderClassifier.from_hparams(
|
||||
source="speechbrain/spkrec-ecapa-voxceleb",
|
||||
savedir=SPEECHBRAIN_CACHE,
|
||||
run_opts={"device": device},
|
||||
)
|
||||
_speechbrain_model = classifier
|
||||
logger.info(f"SpeechBrain ECAPA-TDNN loaded on {device}.")
|
||||
except Exception as e:
|
||||
logger.warning(f"SpeechBrain unavailable ({e}), will use MFCC fallback.")
|
||||
_speechbrain_model = None
|
||||
return _speechbrain_model
|
||||
|
||||
|
||||
def _try_load_embedding_model():
|
||||
global _embedding_model, _embedding_tried
|
||||
if _embedding_tried:
|
||||
return _embedding_model
|
||||
_embedding_tried = True
|
||||
try:
|
||||
from pyannote.audio import Model
|
||||
from pyannote.audio import Inference
|
||||
logger.info("Loading pyannote embedding model...")
|
||||
model = Model.from_pretrained("pyannote/embedding", use_auth_token=HF_TOKEN)
|
||||
_embedding_model = Inference(model, window="whole")
|
||||
logger.info("pyannote embedding model loaded.")
|
||||
except Exception:
|
||||
sb = _try_load_speechbrain()
|
||||
if sb is not None:
|
||||
_embedding_model = ("speechbrain", sb)
|
||||
logger.info("Using SpeechBrain for embeddings.")
|
||||
else:
|
||||
logger.warning("No embedding model available. Using MFCC fallback.")
|
||||
_embedding_model = None
|
||||
return _embedding_model
|
||||
|
||||
|
||||
# ── Pyannote diarization ──────────────────────────────────────────────────────
|
||||
def _diarize_pyannote(audio_path: str) -> List[Dict]:
|
||||
"""Run pyannote diarization. Returns list of {start, end, speaker}."""
|
||||
pipeline = _try_load_pyannote()
|
||||
if pipeline is None:
|
||||
raise RuntimeError("pyannote not available")
|
||||
diarization = pipeline(audio_path)
|
||||
segments = []
|
||||
for turn, _, speaker in diarization.itertracks(yield_label=True):
|
||||
segments.append({"start": round(turn.start, 3), "end": round(turn.end, 3), "speaker": speaker})
|
||||
return segments
|
||||
|
||||
|
||||
# ── Shared VAD: detects speech segments using energy thresholding ─────────────
|
||||
def _get_speech_segments(audio_path: str, min_silence_sec: float = 0.5) -> Tuple[np.ndarray, int, List[Dict]]:
|
||||
"""
|
||||
Energy-based Voice Activity Detection.
|
||||
Returns (y, sr, segments) where segments = [{start, end}, ...].
|
||||
"""
|
||||
import librosa
|
||||
|
||||
y, sr = librosa.load(audio_path, sr=16000, mono=True)
|
||||
frame_len = int(0.025 * sr) # 25 ms
|
||||
hop_len = int(0.010 * sr) # 10 ms
|
||||
|
||||
energy = librosa.feature.rms(y=y, frame_length=frame_len, hop_length=hop_len)[0]
|
||||
nonzero_energy = energy[energy > 0]
|
||||
|
||||
if len(nonzero_energy) == 0:
|
||||
duration = len(y) / sr
|
||||
return y, sr, [{"start": 0.0, "end": round(float(duration), 3)}]
|
||||
|
||||
threshold = np.percentile(nonzero_energy, 20)
|
||||
is_speech = energy > threshold
|
||||
times = librosa.frames_to_time(np.arange(len(is_speech)), sr=sr, hop_length=hop_len)
|
||||
min_silence_frames = int(min_silence_sec / (hop_len / sr))
|
||||
|
||||
segments_raw = []
|
||||
in_speech = False
|
||||
seg_start = 0.0
|
||||
silence_count = 0
|
||||
|
||||
for i, (t, sp) in enumerate(zip(times, is_speech)):
|
||||
if sp:
|
||||
if not in_speech:
|
||||
seg_start = t
|
||||
in_speech = True
|
||||
silence_count = 0
|
||||
else:
|
||||
if in_speech:
|
||||
silence_count += 1
|
||||
if silence_count >= min_silence_frames:
|
||||
seg_end = times[i - silence_count] if i >= silence_count else t
|
||||
if seg_end - seg_start > 0.3:
|
||||
segments_raw.append({"start": round(seg_start, 3), "end": round(seg_end, 3)})
|
||||
in_speech = False
|
||||
silence_count = 0
|
||||
|
||||
if in_speech:
|
||||
segments_raw.append({"start": round(seg_start, 3), "end": round(float(times[-1]), 3)})
|
||||
|
||||
if not segments_raw:
|
||||
segments_raw = [{"start": 0.0, "end": round(float(len(y) / sr), 3)}]
|
||||
|
||||
return y, sr, segments_raw
|
||||
|
||||
|
||||
def _estimate_n_speakers(embeddings: np.ndarray, max_speakers: int = 8) -> int:
|
||||
"""
|
||||
Estimate optimal number of speakers using the elbow method on
|
||||
intra-cluster variance. Tries k=1..max_speakers and picks the k where
|
||||
adding one more cluster yields < 15% improvement of total variance range.
|
||||
"""
|
||||
n = len(embeddings)
|
||||
if n <= 1:
|
||||
return 1
|
||||
max_k = min(max_speakers, n)
|
||||
if max_k <= 2:
|
||||
return max_k
|
||||
|
||||
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
||||
norms[norms == 0] = 1e-10
|
||||
normed = embeddings / norms
|
||||
|
||||
variances = []
|
||||
for k in range(1, max_k + 1):
|
||||
labels = _cosine_cluster(normed, n_clusters=k)
|
||||
total_var = 0.0
|
||||
for c in range(k):
|
||||
mask = labels == c
|
||||
if mask.sum() > 1:
|
||||
centroid = normed[mask].mean(axis=0)
|
||||
total_var += float(np.mean(1 - normed[mask] @ centroid))
|
||||
variances.append(total_var)
|
||||
|
||||
if len(variances) < 2:
|
||||
return 1
|
||||
|
||||
total_range = variances[0] - variances[-1]
|
||||
if total_range < 1e-6:
|
||||
return 1
|
||||
|
||||
for k_idx in range(1, len(variances)):
|
||||
improvement = variances[k_idx - 1] - variances[k_idx]
|
||||
if improvement < 0.15 * total_range:
|
||||
return k_idx # k = k_idx (1-indexed)
|
||||
|
||||
return max_k
|
||||
|
||||
|
||||
# ── SpeechBrain ECAPA-TDNN diarization (secondary) ───────────────────────────
|
||||
def _diarize_speechbrain(audio_path: str, num_speakers: Optional[int] = None) -> List[Dict]:
|
||||
"""
|
||||
SpeechBrain ECAPA-TDNN diarization.
|
||||
Uses energy-based VAD for segmentation, ECAPA-TDNN 192-dim embeddings,
|
||||
and cosine k-means clustering. ~85% accuracy vs ~60% for MFCC.
|
||||
"""
|
||||
import torch
|
||||
|
||||
classifier = _try_load_speechbrain()
|
||||
if classifier is None:
|
||||
raise RuntimeError("SpeechBrain not available")
|
||||
|
||||
y, sr, segments_raw = _get_speech_segments(audio_path, min_silence_sec=0.5)
|
||||
if len(segments_raw) == 0:
|
||||
return []
|
||||
|
||||
embeddings = []
|
||||
for seg in segments_raw:
|
||||
start_sample = int(seg["start"] * sr)
|
||||
end_sample = int(seg["end"] * sr)
|
||||
chunk = y[start_sample:end_sample]
|
||||
min_samples = int(0.1 * sr) # 100ms minimum
|
||||
if len(chunk) < min_samples:
|
||||
embeddings.append(np.zeros(192))
|
||||
continue
|
||||
waveform = torch.tensor(chunk).unsqueeze(0).float()
|
||||
try:
|
||||
with torch.no_grad():
|
||||
emb = classifier.encode_batch(waveform)
|
||||
embeddings.append(emb.squeeze().cpu().numpy())
|
||||
except Exception as e:
|
||||
logger.warning(f"ECAPA-TDNN embedding failed for segment: {e}")
|
||||
embeddings.append(np.zeros(192))
|
||||
|
||||
embeddings_arr = np.array(embeddings)
|
||||
|
||||
n_speakers = num_speakers if num_speakers else _estimate_n_speakers(embeddings_arr, max_speakers=8)
|
||||
n_speakers = max(1, min(n_speakers, len(segments_raw)))
|
||||
|
||||
labels = _cosine_cluster(embeddings_arr, n_clusters=n_speakers)
|
||||
|
||||
for seg, label in zip(segments_raw, labels):
|
||||
seg["speaker"] = f"SPEAKER_{(label + 1):02d}"
|
||||
|
||||
return segments_raw
|
||||
|
||||
|
||||
# ── Energy + MFCC fallback diarization ───────────────────────────────────────
|
||||
def _diarize_energy(audio_path: str, min_silence_sec: float = 0.5, num_speakers: Optional[int] = None) -> List[Dict]:
|
||||
"""
|
||||
Energy-based VAD + MFCC cosine clustering fallback (~60% accuracy).
|
||||
Used when both pyannote and SpeechBrain are unavailable.
|
||||
"""
|
||||
import librosa
|
||||
|
||||
y, sr, segments_raw = _get_speech_segments(audio_path, min_silence_sec=min_silence_sec)
|
||||
|
||||
if len(segments_raw) == 0:
|
||||
return []
|
||||
|
||||
frame_len = int(0.025 * sr)
|
||||
|
||||
def seg_mfcc(s):
|
||||
start_sample = int(s["start"] * sr)
|
||||
end_sample = int(s["end"] * sr)
|
||||
chunk = y[start_sample:end_sample]
|
||||
if len(chunk) < frame_len:
|
||||
return np.zeros(20)
|
||||
mfcc = librosa.feature.mfcc(y=chunk, sr=sr, n_mfcc=20)
|
||||
return mfcc.mean(axis=1)
|
||||
|
||||
features = np.array([seg_mfcc(s) for s in segments_raw])
|
||||
n_sp = num_speakers if num_speakers else _estimate_n_speakers(features, max_speakers=6)
|
||||
n_sp = max(1, min(n_sp, len(segments_raw)))
|
||||
labels = _cosine_cluster(features, n_clusters=n_sp)
|
||||
|
||||
for seg, label in zip(segments_raw, labels):
|
||||
seg["speaker"] = f"SPEAKER_{(label + 1):02d}"
|
||||
|
||||
return segments_raw
|
||||
|
||||
|
||||
def _cosine_cluster(features: np.ndarray, n_clusters: int) -> np.ndarray:
|
||||
"""Greedy cosine-distance clustering (no sklearn dependency)."""
|
||||
if n_clusters <= 1 or len(features) <= 1:
|
||||
return np.zeros(len(features), dtype=int)
|
||||
|
||||
norms = np.linalg.norm(features, axis=1, keepdims=True)
|
||||
norms[norms == 0] = 1e-10
|
||||
normed = features / norms
|
||||
|
||||
# Initialize centroids as first n_clusters samples
|
||||
centroids = normed[:n_clusters].copy()
|
||||
labels = np.zeros(len(features), dtype=int)
|
||||
|
||||
for _ in range(20): # max iterations
|
||||
# Assign to nearest centroid (highest cosine similarity)
|
||||
sims = normed @ centroids.T # (N, K)
|
||||
new_labels = np.argmax(sims, axis=1)
|
||||
|
||||
if np.all(new_labels == labels):
|
||||
break
|
||||
labels = new_labels
|
||||
|
||||
# Update centroids
|
||||
for k in range(n_clusters):
|
||||
mask = labels == k
|
||||
if mask.any():
|
||||
centroids[k] = normed[mask].mean(axis=0)
|
||||
norm = np.linalg.norm(centroids[k])
|
||||
if norm > 0:
|
||||
centroids[k] /= norm
|
||||
|
||||
return labels
|
||||
|
||||
|
||||
# ── Public diarization entry point ───────────────────────────────────────────
|
||||
def diarize(audio_path: str, num_speakers: Optional[int] = None) -> Tuple[List[Dict], str]:
|
||||
"""
|
||||
Returns (segments, method) where:
|
||||
segments: [{start, end, speaker}, ...]
|
||||
method: "pyannote" | "speechbrain" | "energy_fallback"
|
||||
|
||||
Priority: pyannote → SpeechBrain ECAPA-TDNN → energy+MFCC
|
||||
"""
|
||||
try:
|
||||
segs = _diarize_pyannote(audio_path)
|
||||
return segs, "pyannote"
|
||||
except Exception as e:
|
||||
logger.warning(f"pyannote diarization failed ({e}), trying SpeechBrain")
|
||||
|
||||
try:
|
||||
segs = _diarize_speechbrain(audio_path, num_speakers=num_speakers)
|
||||
return segs, "speechbrain"
|
||||
except Exception as e:
|
||||
logger.warning(f"SpeechBrain diarization failed ({e}), using MFCC fallback")
|
||||
|
||||
segs = _diarize_energy(audio_path, num_speakers=num_speakers)
|
||||
return segs, "energy_fallback"
|
||||
|
||||
|
||||
# ── Transcription + diarization alignment ────────────────────────────────────
|
||||
def align_transcription_with_diarization(
|
||||
transcription_segments: List[Dict],
|
||||
diarization_segments: List[Dict],
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
For each transcription segment {start, end, text}, find the speaker with
|
||||
the most time overlap from diarization_segments.
|
||||
Falls back to temporally closest speaker if no overlap.
|
||||
Merges consecutive same-speaker segments < 1 second apart.
|
||||
"""
|
||||
result = []
|
||||
|
||||
for tseg in transcription_segments:
|
||||
t_start = tseg.get("start", 0.0)
|
||||
t_end = tseg.get("end", t_start + 0.1)
|
||||
text = tseg.get("text", "").strip()
|
||||
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# Find best speaker by overlap
|
||||
best_speaker = None
|
||||
best_overlap = -1.0
|
||||
best_dist = float("inf")
|
||||
|
||||
for dseg in diarization_segments:
|
||||
d_start = dseg["start"]
|
||||
d_end = dseg["end"]
|
||||
speaker = dseg["speaker"]
|
||||
|
||||
overlap = max(0.0, min(t_end, d_end) - max(t_start, d_start))
|
||||
if overlap > best_overlap:
|
||||
best_overlap = overlap
|
||||
best_speaker = speaker
|
||||
|
||||
# Track closest for fallback
|
||||
dist = min(abs(t_start - d_start), abs(t_start - d_end),
|
||||
abs(t_end - d_start), abs(t_end - d_end))
|
||||
if dist < best_dist:
|
||||
best_dist = dist
|
||||
if best_overlap <= 0:
|
||||
best_speaker = speaker
|
||||
|
||||
if best_speaker is None:
|
||||
best_speaker = "SPEAKER_01"
|
||||
|
||||
result.append({
|
||||
"start": round(t_start, 3),
|
||||
"end": round(t_end, 3),
|
||||
"speaker": best_speaker,
|
||||
"text": text,
|
||||
})
|
||||
|
||||
# Merge consecutive same-speaker segments where gap < 1 second
|
||||
if not result:
|
||||
return result
|
||||
|
||||
merged = [result[0].copy()]
|
||||
for seg in result[1:]:
|
||||
prev = merged[-1]
|
||||
gap = seg["start"] - prev["end"]
|
||||
if seg["speaker"] == prev["speaker"] and gap < 1.0:
|
||||
prev["end"] = seg["end"]
|
||||
prev["text"] = prev["text"].rstrip() + " " + seg["text"].lstrip()
|
||||
else:
|
||||
merged.append(seg.copy())
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
# ── Embedding extraction ──────────────────────────────────────────────────────
|
||||
def extract_embedding(audio_path: str) -> np.ndarray:
|
||||
"""
|
||||
Extract speaker embedding from audio file.
|
||||
Returns 1-D numpy array.
|
||||
Falls back to MFCC-based embedding if models unavailable.
|
||||
"""
|
||||
model = _try_load_embedding_model()
|
||||
|
||||
if model is not None:
|
||||
if isinstance(model, tuple) and model[0] == "speechbrain":
|
||||
_, classifier = model
|
||||
try:
|
||||
import torch
|
||||
import torchaudio
|
||||
waveform, sr = torchaudio.load(audio_path)
|
||||
if sr != 16000:
|
||||
resampler = torchaudio.transforms.Resample(sr, 16000)
|
||||
waveform = resampler(waveform)
|
||||
embedding = classifier.encode_batch(waveform)
|
||||
return embedding.squeeze().cpu().numpy()
|
||||
except Exception as e:
|
||||
logger.warning(f"SpeechBrain embedding failed: {e}")
|
||||
else:
|
||||
# pyannote Inference
|
||||
try:
|
||||
embedding = model(audio_path)
|
||||
if hasattr(embedding, 'data'):
|
||||
return np.array(embedding.data).flatten()
|
||||
return np.array(embedding).flatten()
|
||||
except Exception as e:
|
||||
logger.warning(f"pyannote embedding failed: {e}")
|
||||
|
||||
# MFCC fallback
|
||||
return _mfcc_embedding(audio_path)
|
||||
|
||||
|
||||
def _mfcc_embedding(audio_path: str) -> np.ndarray:
|
||||
"""Create a simple MFCC-based speaker embedding (128-dim)."""
|
||||
import librosa
|
||||
y, sr = librosa.load(audio_path, sr=16000, mono=True)
|
||||
mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=40)
|
||||
delta = librosa.feature.delta(mfcc)
|
||||
delta2 = librosa.feature.delta(mfcc, order=2)
|
||||
features = np.concatenate([
|
||||
mfcc.mean(axis=1), mfcc.std(axis=1),
|
||||
delta.mean(axis=1),
|
||||
delta2.mean(axis=1),
|
||||
])
|
||||
norm = np.linalg.norm(features)
|
||||
if norm > 0:
|
||||
features = features / norm
|
||||
return features.astype(np.float32)
|
||||
|
||||
|
||||
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
|
||||
"""Cosine similarity between two vectors."""
|
||||
na = np.linalg.norm(a)
|
||||
nb = np.linalg.norm(b)
|
||||
if na == 0 or nb == 0:
|
||||
return 0.0
|
||||
return float(np.dot(a, b) / (na * nb))
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
"""
|
||||
Voice enrollment and speaker identification service.
|
||||
Uses SQLite at /opt/Backend/whisper-diarization-api/voice_profiles.db
|
||||
"""
|
||||
import os
|
||||
import uuid
|
||||
import pickle
|
||||
import logging
|
||||
import sqlite3
|
||||
import numpy as np
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional, Dict, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = os.getenv("VOICE_DB_PATH", "/opt/Backend/whisper-diarization-api/voice_profiles.db")
|
||||
CONFIDENCE_THRESHOLD = float(os.getenv("SPEAKER_CONFIDENCE_THRESHOLD", "0.75"))
|
||||
|
||||
|
||||
def _conn():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ── Schema ───────────────────────────────────────────────────────────────────
|
||||
def init_db():
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
with _conn() as conn:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS voice_profiles (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
metadata TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS voice_embeddings (
|
||||
id TEXT PRIMARY KEY,
|
||||
profile_id TEXT NOT NULL REFERENCES voice_profiles(id) ON DELETE CASCADE,
|
||||
embedding BLOB NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
""")
|
||||
logger.info(f"Voice profiles DB initialised at {DB_PATH}")
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _serialize(arr: np.ndarray) -> bytes:
|
||||
return pickle.dumps(arr)
|
||||
|
||||
|
||||
def _deserialize(blob: bytes) -> np.ndarray:
|
||||
return pickle.loads(blob)
|
||||
|
||||
|
||||
def _profile_row_to_dict(row: sqlite3.Row, embeddings_count: int = 0) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"email": row["email"],
|
||||
"metadata": row["metadata"],
|
||||
"embeddings_count": embeddings_count,
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
|
||||
# ── CRUD: Voice Profiles ──────────────────────────────────────────────────────
|
||||
def create_profile(name: str, email: Optional[str], metadata: Optional[str]) -> Dict[str, Any]:
|
||||
profile_id = str(uuid.uuid4())
|
||||
now = _now()
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_profiles (id, name, email, metadata, created_at, updated_at) VALUES (?,?,?,?,?,?)",
|
||||
(profile_id, name, email, metadata, now, now),
|
||||
)
|
||||
return {
|
||||
"id": profile_id,
|
||||
"name": name,
|
||||
"email": email,
|
||||
"metadata": metadata,
|
||||
"embeddings_count": 0,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
|
||||
|
||||
def get_profile(profile_id: str) -> Optional[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
row = conn.execute("SELECT * FROM voice_profiles WHERE id = ?", (profile_id,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM voice_embeddings WHERE profile_id = ?", (profile_id,)
|
||||
).fetchone()[0]
|
||||
return _profile_row_to_dict(row, count)
|
||||
|
||||
|
||||
def list_profiles() -> List[Dict[str, Any]]:
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("SELECT * FROM voice_profiles ORDER BY created_at DESC").fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM voice_embeddings WHERE profile_id = ?", (row["id"],)
|
||||
).fetchone()[0]
|
||||
result.append(_profile_row_to_dict(row, count))
|
||||
return result
|
||||
|
||||
|
||||
def update_profile(
|
||||
profile_id: str,
|
||||
name: Optional[str] = None,
|
||||
email: Optional[str] = None,
|
||||
metadata: Optional[str] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
profile = get_profile(profile_id)
|
||||
if profile is None:
|
||||
return None
|
||||
|
||||
new_name = name if name is not None else profile["name"]
|
||||
new_email = email if email is not None else profile["email"]
|
||||
new_meta = metadata if metadata is not None else profile["metadata"]
|
||||
now = _now()
|
||||
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"UPDATE voice_profiles SET name=?, email=?, metadata=?, updated_at=? WHERE id=?",
|
||||
(new_name, new_email, new_meta, now, profile_id),
|
||||
)
|
||||
return get_profile(profile_id)
|
||||
|
||||
|
||||
def delete_profile(profile_id: str) -> bool:
|
||||
with _conn() as conn:
|
||||
cur = conn.execute("DELETE FROM voice_profiles WHERE id = ?", (profile_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
# ── Embeddings ────────────────────────────────────────────────────────────────
|
||||
def add_embedding(profile_id: str, embedding: np.ndarray) -> str:
|
||||
emb_id = str(uuid.uuid4())
|
||||
now = _now()
|
||||
with _conn() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO voice_embeddings (id, profile_id, embedding, created_at) VALUES (?,?,?,?)",
|
||||
(emb_id, profile_id, _serialize(embedding), now),
|
||||
)
|
||||
conn.execute("UPDATE voice_profiles SET updated_at=? WHERE id=?", (now, profile_id))
|
||||
return emb_id
|
||||
|
||||
|
||||
def get_all_embeddings() -> List[Dict[str, Any]]:
|
||||
"""Returns all embeddings with profile info."""
|
||||
with _conn() as conn:
|
||||
rows = conn.execute("""
|
||||
SELECT ve.id, ve.profile_id, ve.embedding, vp.name
|
||||
FROM voice_embeddings ve
|
||||
JOIN voice_profiles vp ON ve.profile_id = vp.id
|
||||
""").fetchall()
|
||||
return [
|
||||
{
|
||||
"embedding_id": r["id"],
|
||||
"profile_id": r["profile_id"],
|
||||
"speaker_name": r["name"],
|
||||
"embedding": _deserialize(r["embedding"]),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ── Speaker identification ────────────────────────────────────────────────────
|
||||
def identify_speaker(query_embedding: np.ndarray, threshold: Optional[float] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Compare query_embedding against all registered speakers.
|
||||
Returns the best match if confidence >= threshold.
|
||||
"""
|
||||
from services.diarization import cosine_similarity
|
||||
|
||||
if threshold is None:
|
||||
threshold = CONFIDENCE_THRESHOLD
|
||||
|
||||
all_embs = get_all_embeddings()
|
||||
if not all_embs:
|
||||
return {"matched": False, "speaker_id": None, "speaker_name": None, "confidence": 0.0, "threshold": threshold}
|
||||
|
||||
# Average embeddings per profile
|
||||
profile_map: Dict[str, Dict[str, Any]] = {}
|
||||
for item in all_embs:
|
||||
pid = item["profile_id"]
|
||||
if pid not in profile_map:
|
||||
profile_map[pid] = {"speaker_name": item["speaker_name"], "embeddings": []}
|
||||
profile_map[pid]["embeddings"].append(item["embedding"])
|
||||
|
||||
best_profile_id = None
|
||||
best_score = -1.0
|
||||
|
||||
for pid, info in profile_map.items():
|
||||
# Average cosine similarity against all stored embeddings
|
||||
scores = [cosine_similarity(query_embedding, e) for e in info["embeddings"]]
|
||||
avg_score = float(np.mean(scores))
|
||||
if avg_score > best_score:
|
||||
best_score = avg_score
|
||||
best_profile_id = pid
|
||||
|
||||
if best_profile_id is None or best_score < threshold:
|
||||
return {
|
||||
"matched": False,
|
||||
"speaker_id": best_profile_id,
|
||||
"speaker_name": profile_map[best_profile_id]["speaker_name"] if best_profile_id else None,
|
||||
"confidence": round(best_score, 4),
|
||||
"threshold": threshold,
|
||||
}
|
||||
|
||||
return {
|
||||
"matched": True,
|
||||
"speaker_id": best_profile_id,
|
||||
"speaker_name": profile_map[best_profile_id]["speaker_name"],
|
||||
"confidence": round(best_score, 4),
|
||||
"threshold": threshold,
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --legacy-peer-deps
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## React Compiler
|
||||
|
||||
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).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
#!/usr/bin/env python3
|
||||
import paramiko
|
||||
import os
|
||||
import tarfile
|
||||
import io
|
||||
from pathlib import Path
|
||||
import time
|
||||
|
||||
# Server details
|
||||
HOST = '5.0.0.181'
|
||||
USER = 'root'
|
||||
PASSWORD = 'Ozzyos1bsd3'
|
||||
REMOTE_PATH = '/opt/Frontend/meeting-assistant-fe/'
|
||||
|
||||
try:
|
||||
print('[1] Connecting to server...')
|
||||
ssh = paramiko.SSHClient()
|
||||
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
ssh.connect(HOST, username=USER, password=PASSWORD, timeout=30)
|
||||
print(f' ✓ Connected to {HOST}')
|
||||
|
||||
# Stop and remove container
|
||||
print('[2] Stopping and removing container...')
|
||||
stdin, stdout, stderr = ssh.exec_command('podman stop meeting-assistant-fe && podman rm meeting-assistant-fe 2>/dev/null || true')
|
||||
stdout.channel.recv_exit_status()
|
||||
print(' ✓ Container stopped and removed')
|
||||
|
||||
# Create tar archive in memory
|
||||
print('[3] Creating tar archive of project...')
|
||||
tar_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=tar_buffer, mode='w:gz') as tar:
|
||||
project_dir = Path('.')
|
||||
exclude_dirs = {'node_modules', '.git', '.next', 'dist', '.env', '.env.local', '.DS_Store'}
|
||||
|
||||
for item in project_dir.rglob('*'):
|
||||
# Skip excluded directories
|
||||
if any(excl in item.parts for excl in exclude_dirs):
|
||||
continue
|
||||
if item.is_file():
|
||||
arcname = str(item.relative_to(project_dir))
|
||||
tar.add(item, arcname=arcname)
|
||||
|
||||
tar_buffer.seek(0)
|
||||
tar_size = len(tar_buffer.getvalue())
|
||||
print(f' ✓ Archive created ({tar_size / 1024 / 1024:.2f} MB)')
|
||||
|
||||
# Upload via SFTP
|
||||
print('[4] Uploading project to server...')
|
||||
sftp = ssh.open_sftp()
|
||||
remote_tar = f'{REMOTE_PATH}project.tar.gz'
|
||||
sftp.putfo(tar_buffer, remote_tar)
|
||||
sftp.close()
|
||||
print(f' ✓ Uploaded to {remote_tar}')
|
||||
|
||||
# Extract on server
|
||||
print('[5] Extracting archive on server...')
|
||||
stdin, stdout, stderr = ssh.exec_command(f'cd {REMOTE_PATH} && tar -xzf project.tar.gz && rm project.tar.gz')
|
||||
stdout.channel.recv_exit_status()
|
||||
print(' ✓ Archive extracted')
|
||||
|
||||
# Rebuild image
|
||||
print('[6] Rebuilding Docker image...')
|
||||
stdin, stdout, stderr = ssh.exec_command(f'cd {REMOTE_PATH} && podman build -t meeting-assistant-fe . 2>&1')
|
||||
build_output = stdout.read().decode()
|
||||
build_err = stderr.read().decode()
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
if exit_code != 0:
|
||||
print(f' ⚠️ Build warnings/info:\n{build_output}\n{build_err}')
|
||||
print(' ✓ Image built')
|
||||
|
||||
# Start container
|
||||
print('[7] Starting new container...')
|
||||
stdin, stdout, stderr = ssh.exec_command('podman run -d --name meeting-assistant-fe --restart unless-stopped -p 3010:80 meeting-assistant-fe')
|
||||
container_id = stdout.read().decode().strip()
|
||||
stdout.channel.recv_exit_status()
|
||||
print(f' ✓ Container started (ID: {container_id[:12]}...)')
|
||||
|
||||
# Wait for container to be ready
|
||||
time.sleep(2)
|
||||
|
||||
# Verify
|
||||
print('[8] Verifying deployment...')
|
||||
stdin, stdout, stderr = ssh.exec_command('curl -s -o /dev/null -w "%{http_code}" http://localhost:3010/')
|
||||
exit_code = stdout.channel.recv_exit_status()
|
||||
response = stdout.read().decode().strip()
|
||||
print(f' ✓ Response code: {response}')
|
||||
|
||||
if response == '200':
|
||||
print('\n✅ Deployment successful! PWA is live at http://5.0.0.181:3010/')
|
||||
else:
|
||||
print(f'\n⚠️ Unexpected response code: {response}')
|
||||
# Check container logs
|
||||
stdin, stdout, stderr = ssh.exec_command('podman logs meeting-assistant-fe --tail 20')
|
||||
logs = stdout.read().decode()
|
||||
print(f'Container logs:\n{logs}')
|
||||
|
||||
ssh.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f'\n❌ Deployment failed: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
version: '3.8'
|
||||
services:
|
||||
meeting-assistant-fe:
|
||||
build: .
|
||||
container_name: meeting-assistant-fe
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:80"
|
||||
networks:
|
||||
- meeting-network
|
||||
|
||||
networks:
|
||||
meeting-network:
|
||||
driver: bridge
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MAIA — Meeting AI Assistant</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy to backend API (avoids CORS)
|
||||
location /api/ {
|
||||
proxy_pass http://5.0.0.181:8090/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket proxy
|
||||
location /ws/ {
|
||||
proxy_pass http://5.0.0.181:8090/ws/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
# Service Worker e manifest — NUNCA cachear (browser precisa verificar updates)
|
||||
location ~* (sw\.js|workbox-.*\.js|registerSW\.js|manifest\.webmanifest)$ {
|
||||
expires -1;
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
add_header Pragma "no-cache";
|
||||
}
|
||||
|
||||
# Cache assets versionados por hash (imutáveis)
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# Cache outros estáticos (ícones, fontes)
|
||||
location ~* \.(png|svg|ico|woff2)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public";
|
||||
}
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
{
|
||||
"name": "meeting-assistant-fe",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"axios": "^1.15.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"idb": "^8.0.3",
|
||||
"keycloak-js": "^26.2.4",
|
||||
"lucide-react": "^1.8.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-is": "^19.2.5",
|
||||
"react-router-dom": "^7.14.1",
|
||||
"recharts": "^3.8.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.0",
|
||||
"vite": "^8.0.4",
|
||||
"vite-plugin-pwa": "^1.2.0"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 9.3 KiB |
|
|
@ -0,0 +1,24 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
|
|
@ -0,0 +1,2 @@
|
|||
# Icons
|
||||
Place icon-192.png and icon-512.png here for PWA installation.
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
|
@ -0,0 +1,184 @@
|
|||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
import { Home } from '@/pages/Home'
|
||||
import { Recording } from '@/pages/Recording'
|
||||
import { UploadPage } from '@/pages/Upload'
|
||||
import { History } from '@/pages/History'
|
||||
import { MeetingDetail } from '@/pages/MeetingDetail'
|
||||
import { Participants } from '@/pages/Participants'
|
||||
import { Evolution } from '@/pages/Evolution'
|
||||
import { Settings } from '@/pages/Settings'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<ProtectedRoute><Home /></ProtectedRoute>} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><Home /></ProtectedRoute>} />
|
||||
<Route path="/record" element={<ProtectedRoute><Recording /></ProtectedRoute>} />
|
||||
<Route path="/upload" element={<ProtectedRoute><UploadPage /></ProtectedRoute>} />
|
||||
<Route path="/history" element={<ProtectedRoute><History /></ProtectedRoute>} />
|
||||
<Route path="/meetings/:id" element={<ProtectedRoute><MeetingDetail /></ProtectedRoute>} />
|
||||
<Route path="/participants" element={<ProtectedRoute><Participants /></ProtectedRoute>} />
|
||||
<Route path="/evolution" element={<ProtectedRoute><Evolution /></ProtectedRoute>} />
|
||||
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
|
||||
{/* Keycloak handles auth — redirect legacy login/register paths to home */}
|
||||
<Route path="/login" element={<Navigate to="/" replace />} />
|
||||
<Route path="/register" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import api from './axios'
|
||||
import type { User } from '@/types'
|
||||
|
||||
export const authApi = {
|
||||
login: (email: string, password: string) =>
|
||||
api.post<{ access_token: string; refresh_token: string; user: User }>('/auth/login', { email, password }),
|
||||
|
||||
register: (name: string, email: string, password: string) =>
|
||||
api.post<{ access_token: string; refresh_token: string; user: User }>('/auth/register', { name, email, password }),
|
||||
|
||||
refresh: (refresh_token: string) =>
|
||||
api.post<{ access_token: string }>('/auth/refresh', { refresh_token }),
|
||||
|
||||
keycloakAuth: async (kcToken: string) => {
|
||||
const response = await api.post<{ access_token: string; refresh_token: string; token_type: string }>(
|
||||
'/auth/keycloak',
|
||||
{ kc_token: kcToken }
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import axios from 'axios'
|
||||
import keycloak from '@/services/keycloak'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (error) => {
|
||||
const original = error.config
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true
|
||||
const refreshToken = localStorage.getItem('refresh_token')
|
||||
if (refreshToken) {
|
||||
try {
|
||||
const { data } = await axios.post('/api/auth/refresh', { refresh_token: refreshToken })
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
original.headers.Authorization = `Bearer ${data.access_token}`
|
||||
return api(original)
|
||||
} catch {
|
||||
// Refresh failed — try re-exchanging via Keycloak
|
||||
try {
|
||||
await keycloak.updateToken(10)
|
||||
if (keycloak.token) {
|
||||
const { data } = await axios.post('/api/auth/keycloak', { kc_token: keycloak.token })
|
||||
localStorage.setItem('access_token', data.access_token)
|
||||
localStorage.setItem('refresh_token', data.refresh_token)
|
||||
original.headers.Authorization = `Bearer ${data.access_token}`
|
||||
return api(original)
|
||||
}
|
||||
} catch {
|
||||
// Keycloak session also expired — force re-login
|
||||
}
|
||||
useAuthStore.getState().logout()
|
||||
keycloak.login()
|
||||
}
|
||||
} else {
|
||||
useAuthStore.getState().logout()
|
||||
keycloak.login()
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import api from './axios'
|
||||
import type { Meeting, MeetingAnalysis, Transcript } from '@/types'
|
||||
|
||||
export const meetingsApi = {
|
||||
list: () => api.get<Meeting[]>('/meetings'),
|
||||
|
||||
get: (id: string) => api.get<Meeting>(`/meetings/${id}`),
|
||||
|
||||
getStatus: (id: string) => api.get<{ status: string; progress: number }>(`/meetings/${id}/status`),
|
||||
|
||||
getTranscript: (id: string) => api.get<Transcript>(`/meetings/${id}/transcript`),
|
||||
|
||||
getAnalyses: (id: string) => api.get<MeetingAnalysis[]>(`/meetings/${id}/analyses`),
|
||||
|
||||
getAnalysis: (id: string, type: string) => api.get<MeetingAnalysis>(`/meetings/${id}/analyses/${type}`),
|
||||
|
||||
upload: (file: File, title?: string, onProgress?: (p: number) => void) => {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
const params = title ? { title } : {}
|
||||
return api.post<Meeting>('/meetings/upload', form, {
|
||||
params,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (e) => {
|
||||
if (onProgress && e.total) onProgress(Math.round((e.loaded * 100) / e.total))
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
// --- Chunk recording endpoints ---
|
||||
|
||||
startMeeting: async (title?: string): Promise<Meeting> => {
|
||||
const { data } = await api.post<Meeting>('/meetings/start', null, { params: title?.trim() ? { title } : {} })
|
||||
return data
|
||||
},
|
||||
|
||||
uploadChunk: async (meetingId: string, chunkIndex: number, blob: Blob, mimeType: string): Promise<void> => {
|
||||
const form = new FormData()
|
||||
const ext = mimeType.includes('ogg') ? '.ogg' : mimeType.includes('mp4') ? '.mp4' : '.webm'
|
||||
form.append('file', new File([blob], `chunk_${chunkIndex}${ext}`, { type: mimeType }))
|
||||
form.append('chunk_index', String(chunkIndex))
|
||||
await api.post(`/meetings/${meetingId}/chunks`, form)
|
||||
},
|
||||
|
||||
finalizeMeeting: async (meetingId: string, title?: string): Promise<Meeting> => {
|
||||
const params = title ? { title } : {}
|
||||
const { data } = await api.post<Meeting>(`/meetings/${meetingId}/finalize`, null, { params })
|
||||
return data
|
||||
},
|
||||
|
||||
relabelParticipant: (meetingId: string, label: string, name: string) =>
|
||||
api.put(`/meetings/${meetingId}/participants/${label}`, { name }),
|
||||
|
||||
enrollSpeaker: async (meetingId: string, speakerLabel: string, name: string, email?: string): Promise<void> => {
|
||||
await api.post(`/meetings/${meetingId}/participants/${encodeURIComponent(speakerLabel)}/enroll`, { name, email })
|
||||
},
|
||||
|
||||
delete: (id: string) => api.delete(`/meetings/${id}`),
|
||||
|
||||
getAudioUrl: (id: string): string => {
|
||||
return `/api/meetings/${id}/audio`
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import api from './axios'
|
||||
import type { Participant } from '@/types'
|
||||
|
||||
export const participantsApi = {
|
||||
list: () => api.get<Participant[]>('/participants'),
|
||||
|
||||
create: (name: string, email?: string) =>
|
||||
api.post<Participant>('/participants', { name, email }),
|
||||
|
||||
delete: (id: string) => api.delete(`/participants/${id}`),
|
||||
}
|
||||
|
After Width: | Height: | Size: 44 KiB |
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
|
@ -0,0 +1,65 @@
|
|||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { Mic, History, Users, TrendingUp, Settings, LogOut } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: Mic, label: 'Início' },
|
||||
{ to: '/history', icon: History, label: 'Histórico' },
|
||||
{ to: '/participants', icon: Users, label: 'Participantes' },
|
||||
{ to: '/evolution', icon: TrendingUp, label: 'Evolução' },
|
||||
{ to: '/settings', icon: Settings, label: 'Config.' },
|
||||
]
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const navigate = useNavigate()
|
||||
const { logout, user } = useAuthStore()
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col min-h-dvh bg-slate-900">
|
||||
{/* Header */}
|
||||
<header className="flex items-center justify-between px-4 py-2 bg-slate-800 border-b border-slate-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<img src="/images/logo-asap.png" alt="ASAP Telecom" className="h-7 rounded bg-white px-1.5 py-0.5" />
|
||||
<span className="text-green-400 font-bold text-lg tracking-wide">MAIA</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{user?.name && (
|
||||
<span className="text-slate-300 text-sm hidden sm:block truncate max-w-[160px]">
|
||||
{user.name}
|
||||
</span>
|
||||
)}
|
||||
<button onClick={handleLogout} className="text-slate-400 hover:text-white p-1">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="flex-1 overflow-auto pb-20">{children}</main>
|
||||
|
||||
{/* Bottom Nav */}
|
||||
<nav className="fixed bottom-0 left-0 right-0 bg-slate-800 border-t border-slate-700 flex justify-around py-2 z-50">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === '/'}
|
||||
className={({ isActive }) =>
|
||||
cn('flex flex-col items-center gap-0.5 px-3 py-1 rounded-lg text-xs transition-colors',
|
||||
isActive ? 'text-green-400' : 'text-slate-400 hover:text-slate-200')
|
||||
}
|
||||
>
|
||||
<Icon size={20} />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { useAuthStore } from '@/store/authStore'
|
||||
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
if (!isAuthenticated) return null
|
||||
return <>{children}</>
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Badge } from '@/components/ui/badge'
|
||||
import type { Meeting } from '@/types'
|
||||
|
||||
const map: Record<Meeting['status'], { label: string; variant: 'default' | 'secondary' | 'warning' | 'destructive' | 'info' }> = {
|
||||
receiving_chunks: { label: 'Gravando', variant: 'info' },
|
||||
uploading: { label: 'Enviando', variant: 'info' },
|
||||
processing: { label: 'Processando', variant: 'warning' },
|
||||
transcribing: { label: 'Transcrevendo', variant: 'warning' },
|
||||
analyzing: { label: 'Analisando', variant: 'warning' },
|
||||
completed: { label: 'Concluído', variant: 'default' },
|
||||
error: { label: 'Erro', variant: 'destructive' },
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: Meeting['status'] }) {
|
||||
const { label, variant } = map[status] ?? { label: status, variant: 'secondary' }
|
||||
return <Badge variant={variant}>{label}</Badge>
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { cn } from '@/utils/cn'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
const badgeVariants = cva('inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold', {
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-green-900 text-green-300',
|
||||
secondary: 'bg-slate-700 text-slate-300',
|
||||
destructive: 'bg-red-900 text-red-300',
|
||||
warning: 'bg-yellow-900 text-yellow-300',
|
||||
info: 'bg-blue-900 text-blue-300',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
})
|
||||
|
||||
export function Badge({ className, variant, ...props }: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { forwardRef } from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 rounded-lg font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-green-500 disabled:opacity-50 disabled:pointer-events-none select-none',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-green-700 text-white hover:bg-green-600',
|
||||
destructive: 'bg-red-600 text-white hover:bg-red-500',
|
||||
outline: 'border border-slate-600 text-slate-200 hover:bg-slate-700',
|
||||
ghost: 'text-slate-300 hover:bg-slate-700 hover:text-white',
|
||||
secondary: 'bg-slate-700 text-slate-200 hover:bg-slate-600',
|
||||
},
|
||||
size: {
|
||||
default: 'h-10 px-4 py-2 text-sm',
|
||||
sm: 'h-8 px-3 text-xs',
|
||||
lg: 'h-12 px-6 text-base',
|
||||
icon: 'h-10 w-10',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default', size: 'default' },
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => (
|
||||
<button ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props} />
|
||||
)
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { cn } from '@/utils/cn'
|
||||
|
||||
export function Card({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('rounded-xl border border-slate-700 bg-slate-800 p-4', className)} {...props} />
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('mb-3', className)} {...props} />
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return <h3 className={cn('text-base font-semibold text-white', className)} {...props} />
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('text-slate-300 text-sm', className)} {...props} />
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { forwardRef } from 'react'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-sm text-white placeholder:text-slate-400 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { useRef, useCallback } from 'react'
|
||||
|
||||
export function useWakeLock() {
|
||||
const wakeLockRef = useRef<WakeLockSentinel | null>(null)
|
||||
|
||||
const acquire = useCallback(async () => {
|
||||
if ('wakeLock' in navigator) {
|
||||
try {
|
||||
wakeLockRef.current = await navigator.wakeLock.request('screen')
|
||||
} catch {
|
||||
// Not critical — iOS Safari doesn't support this
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const release = useCallback(async () => {
|
||||
if (wakeLockRef.current) {
|
||||
await wakeLockRef.current.release()
|
||||
wakeLockRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
return { acquire, release }
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { useEffect, useRef, useCallback } from 'react'
|
||||
|
||||
export function useWebSocket(meetingId: string | null, onMessage: (data: unknown) => void) {
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!meetingId) return
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/meetings/${meetingId}`)
|
||||
ws.onmessage = (e) => {
|
||||
try { onMessage(JSON.parse(e.data)) } catch { /* ignore */ }
|
||||
}
|
||||
ws.onclose = () => {
|
||||
setTimeout(connect, 3000) // auto-reconnect
|
||||
}
|
||||
wsRef.current = ws
|
||||
}, [meetingId, onMessage])
|
||||
|
||||
useEffect(() => {
|
||||
connect()
|
||||
return () => { wsRef.current?.close() }
|
||||
}, [connect])
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #0f172a;
|
||||
color: #e2e8f0;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar { width: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: #334155; border-radius: 3px; }
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
import { useAuthStore } from './store/authStore'
|
||||
import { authApi } from './api/auth'
|
||||
|
||||
const rootElement = document.getElementById('root')!
|
||||
|
||||
function render(el: React.ReactElement) {
|
||||
createRoot(rootElement).render(<React.StrictMode>{el}</React.StrictMode>)
|
||||
}
|
||||
|
||||
function AccessDeniedPage({ forbidden }: { forbidden?: boolean }) {
|
||||
return (
|
||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', background: '#020617', color: 'white', padding: '24px' }}>
|
||||
<img src="/images/logo-asap.png" alt="ASAP Telecom" style={{ height: '48px', background: 'white', borderRadius: '8px', padding: '6px 12px', marginBottom: '20px' }} />
|
||||
<div style={{ fontSize: '48px' }}>{forbidden ? '🚫' : '🔗'}</div>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 'bold', margin: '12px 0 4px' }}>MAIA</h1>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: '500', margin: '0 0 12px', color: '#94a3b8' }}>
|
||||
{forbidden ? 'Acesso Negado' : 'Link inválido ou expirado'}
|
||||
</h2>
|
||||
<p style={{ color: '#94a3b8', textAlign: 'center', maxWidth: '340px', lineHeight: 1.6 }}>
|
||||
{forbidden
|
||||
? 'Seu usuário não possui a permissão maia.app necessária. Entre em contato com o administrador.'
|
||||
: 'Acesse o MAIA pelo link gerado no portal. O link de acesso direto é necessário para entrar.'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
async function initApp() {
|
||||
// 1. Ler token da URL (?token=eyJ...)
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlToken = params.get('token')
|
||||
|
||||
if (urlToken) {
|
||||
// Limpar token da URL imediatamente (antes de qualquer render)
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
|
||||
try {
|
||||
const tokens = await authApi.keycloakAuth(urlToken)
|
||||
// Decodificar payload do token KC para extrair nome/email
|
||||
const payload = JSON.parse(atob(urlToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')))
|
||||
const user = {
|
||||
id: payload.sub as string,
|
||||
email: (payload.email as string) || '',
|
||||
name: (payload.name as string)
|
||||
|| `${payload.given_name || ''} ${payload.family_name || ''}`.trim()
|
||||
|| (payload.preferred_username as string)
|
||||
|| (payload.email as string)
|
||||
|| 'Usuário',
|
||||
roles: [
|
||||
...((payload.realm_access?.roles as string[]) || []),
|
||||
...Object.values(payload.resource_access || {}).flatMap((c: any) => c.roles || []),
|
||||
].filter((r: string) => r.startsWith('maia.')),
|
||||
}
|
||||
useAuthStore.getState().setAuth(user, tokens.access_token, tokens.refresh_token)
|
||||
} catch (e: any) {
|
||||
const status = e?.response?.status
|
||||
render(<AccessDeniedPage forbidden={status === 403} />)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 2. Sem token na URL — verificar sessão salva no localStorage
|
||||
const { isAuthenticated } = useAuthStore.getState()
|
||||
if (!isAuthenticated) {
|
||||
render(<AccessDeniedPage />)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
render(<App />)
|
||||
}
|
||||
|
||||
initApp()
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
export function Login() {
|
||||
const navigate = useNavigate()
|
||||
const setAuth = useAuthStore((s) => s.setAuth)
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const { data } = await authApi.login(email, password)
|
||||
setAuth(data.user, data.access_token, data.refresh_token)
|
||||
navigate('/')
|
||||
} catch {
|
||||
setError('E-mail ou senha inválidos')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-slate-900 flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-4xl mb-3">🎙</div>
|
||||
<img src="/images/logo-asap.png" alt="ASAP Telecom" className="h-8 rounded bg-white px-2 py-1 mx-auto mb-3" />
|
||||
<h1 className="text-2xl font-bold text-white">MAIA</h1>
|
||||
<p className="text-slate-400 text-sm mt-1">Meeting AI Assistant</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && <p className="text-red-400 text-sm text-center bg-red-950 rounded-lg p-3">{error}</p>}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">E-mail</label>
|
||||
<Input type="email" placeholder="seu@email.com" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">Senha</label>
|
||||
<Input type="password" placeholder="••••••••" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" size="lg" disabled={loading}>
|
||||
{loading ? 'Entrando...' : 'Entrar'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="text-center text-slate-400 text-sm mt-6">
|
||||
Não tem conta?{' '}
|
||||
<Link to="/register" className="text-green-400 hover:underline">Cadastrar</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
|
||||
export function Register() {
|
||||
const navigate = useNavigate()
|
||||
const setAuth = useAuthStore((s) => s.setAuth)
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const { data } = await authApi.register(name, email, password)
|
||||
setAuth(data.user, data.access_token, data.refresh_token)
|
||||
navigate('/')
|
||||
} catch {
|
||||
setError('Erro ao criar conta. Tente novamente.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh bg-slate-900 flex flex-col items-center justify-center p-6">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-4xl mb-2">🎙</div>
|
||||
<h1 className="text-2xl font-bold text-white">Criar conta</h1>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && <p className="text-red-400 text-sm text-center bg-red-950 rounded-lg p-3">{error}</p>}
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">Nome</label>
|
||||
<Input placeholder="Seu nome" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">E-mail</label>
|
||||
<Input type="email" placeholder="seu@email.com" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">Senha</label>
|
||||
<Input type="password" placeholder="••••••••" value={password} onChange={(e) => setPassword(e.target.value)} required minLength={6} />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" size="lg" disabled={loading}>
|
||||
{loading ? 'Criando...' : 'Criar conta'}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-center text-slate-400 text-sm mt-6">
|
||||
Já tem conta? <Link to="/login" className="text-green-400 hover:underline">Entrar</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Loader2, TrendingUp } from 'lucide-react'
|
||||
import { meetingsApi } from '@/api/meetings'
|
||||
import { useMeetingsStore } from '@/store/meetingsStore'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import type { CommunicationData } from '@/types'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
|
||||
interface ScorePoint { date: string; score: number; title: string }
|
||||
|
||||
export function Evolution() {
|
||||
const { meetings, fetchMeetings } = useMeetingsStore()
|
||||
const [scores, setScores] = useState<ScorePoint[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
await fetchMeetings()
|
||||
const completed = meetings.filter((m) => m.status === 'completed').slice(0, 20)
|
||||
const points: ScorePoint[] = []
|
||||
for (const m of completed) {
|
||||
try {
|
||||
const { data } = await meetingsApi.getAnalysis(m.id, 'communication')
|
||||
const fb = data.content as CommunicationData
|
||||
const score = fb?.overall_score
|
||||
if (score !== undefined) {
|
||||
points.push({ date: new Date(m.created_at).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' }), score, title: m.title || 'Sem título' })
|
||||
}
|
||||
} catch { /* no communication yet */ }
|
||||
}
|
||||
setScores(points.reverse())
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
const avg = scores.length > 0 ? (scores.reduce((a, b) => a + b.score, 0) / scores.length).toFixed(1) : null
|
||||
const latest = scores[scores.length - 1]?.score
|
||||
const trend = scores.length >= 2 ? latest - scores[scores.length - 2].score : null
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="p-4 max-w-2xl mx-auto space-y-4">
|
||||
<h1 className="text-xl font-bold text-white">Evolução pessoal</h1>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Loader2 size={24} className="animate-spin text-slate-400" /></div>
|
||||
) : scores.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<TrendingUp size={32} className="mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">Nenhuma análise disponível ainda.</p>
|
||||
<p className="text-xs mt-1">Grave reuniões para ver sua evolução!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-3xl font-bold text-white">{latest}<span className="text-base text-slate-400">/10</span></div>
|
||||
<p className="text-slate-400 text-xs mt-1">Score mais recente</p>
|
||||
{trend !== null && (
|
||||
<p className={`text-xs mt-0.5 font-medium ${trend > 0 ? 'text-green-400' : trend < 0 ? 'text-red-400' : 'text-slate-400'}`}>
|
||||
{trend > 0 ? `↑ +${trend.toFixed(1)}` : trend < 0 ? `↓ ${trend.toFixed(1)}` : '→ estável'}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="text-center py-4">
|
||||
<div className="text-3xl font-bold text-white">{avg}<span className="text-base text-slate-400">/10</span></div>
|
||||
<p className="text-slate-400 text-xs mt-1">Média geral</p>
|
||||
<p className="text-slate-500 text-xs mt-0.5">{scores.length} reuniões</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardContent>
|
||||
<h3 className="text-green-400 text-sm font-semibold mb-3">📈 Score ao longo do tempo</h3>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={scores}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="date" tick={{ fill: '#94a3b8', fontSize: 10 }} />
|
||||
<YAxis domain={[0, 10]} tick={{ fill: '#94a3b8', fontSize: 10 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8 }}
|
||||
formatter={(v) => [`${v}/10`, 'Score'] as [string, string]}
|
||||
labelFormatter={(l, payload) => payload?.[0]?.payload?.title ?? l}
|
||||
/>
|
||||
<Line type="monotone" dataKey="score" stroke="#4ade80" strokeWidth={2} dot={{ fill: '#4ade80', r: 4 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Search, Trash2, Loader2, User } from 'lucide-react'
|
||||
import { useMeetingsStore } from '@/store/meetingsStore'
|
||||
import { meetingsApi } from '@/api/meetings'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { StatusBadge } from '@/components/StatusBadge'
|
||||
import { formatDateTimeBR } from '@/utils/format'
|
||||
|
||||
export function History() {
|
||||
const navigate = useNavigate()
|
||||
const { meetings, loading, fetchMeetings, removeMeeting } = useMeetingsStore()
|
||||
const authUser = useAuthStore((s) => s.user)
|
||||
const [search, setSearch] = useState('')
|
||||
|
||||
const isAdmin = authUser?.roles?.includes('maia.admin') ?? false
|
||||
|
||||
useEffect(() => { fetchMeetings() }, [fetchMeetings])
|
||||
|
||||
const filtered = meetings.filter((m) =>
|
||||
(m.title || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||
(isAdmin && (m.owner_name || m.owner_email || '').toLowerCase().includes(search.toLowerCase()))
|
||||
)
|
||||
|
||||
async function handleDelete(e: React.MouseEvent, id: string) {
|
||||
e.stopPropagation()
|
||||
if (!confirm('Excluir esta reunião?')) return
|
||||
try {
|
||||
await meetingsApi.delete(id)
|
||||
removeMeeting(id)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="p-4 max-w-2xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white">Histórico</h1>
|
||||
{isAdmin && (
|
||||
<span className="text-xs bg-amber-500/20 text-amber-400 border border-amber-500/30 px-2 py-0.5 rounded-full">
|
||||
Admin — todas as reuniões
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<Input
|
||||
placeholder={isAdmin ? 'Buscar por título ou usuário...' : 'Buscar reunião...'}
|
||||
className="pl-9"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Loader2 size={24} className="animate-spin text-slate-400" /></div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<p>Nenhuma reunião encontrada.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filtered.map((m) => (
|
||||
<Card key={m.id} className="cursor-pointer hover:border-slate-500" onClick={() => navigate(`/meetings/${m.id}`)}>
|
||||
<CardContent className="flex items-center justify-between p-3">
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<p className="text-white text-sm font-medium truncate">{m.title || 'Reunião sem título'}</p>
|
||||
<p className="text-slate-400 text-xs">{formatDateTimeBR(m.created_at)}</p>
|
||||
{isAdmin && (m.owner_name || m.owner_email) && (
|
||||
<p className="text-amber-400/80 text-xs flex items-center gap-1 mt-0.5">
|
||||
<User size={10} />
|
||||
{m.owner_name || m.owner_email}
|
||||
{m.owner_email && m.owner_name && (
|
||||
<span className="text-slate-500">· {m.owner_email}</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={m.status} />
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, m.id)}
|
||||
className="text-slate-500 hover:text-red-400 p-1"
|
||||
title="Excluir reunião"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Mic, Upload, Clock, CheckCircle, AlertCircle, Loader2 } from 'lucide-react'
|
||||
import { useMeetingsStore } from '@/store/meetingsStore'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { StatusBadge } from '@/components/StatusBadge'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { formatDistanceToNow } from '@/utils/format'
|
||||
|
||||
export function Home() {
|
||||
const navigate = useNavigate()
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const { meetings, loading, fetchMeetings } = useMeetingsStore()
|
||||
|
||||
useEffect(() => { fetchMeetings() }, [fetchMeetings])
|
||||
|
||||
const recent = meetings.slice(0, 5)
|
||||
const processing = meetings.filter((m) => m.status === 'processing' || m.status === 'uploading')
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="p-4 space-y-6 max-w-2xl mx-auto">
|
||||
{/* Greeting */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white">Olá, {user?.name?.split(' ')[0]} 👋</h1>
|
||||
<p className="text-slate-400 text-sm">O que faremos hoje?</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button size="lg" onClick={() => navigate('/record')} className="h-20 flex-col gap-2 text-base">
|
||||
<Mic size={24} />
|
||||
Gravar reunião
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" onClick={() => navigate('/upload')} className="h-20 flex-col gap-2 text-base">
|
||||
<Upload size={24} />
|
||||
Enviar áudio
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Processing */}
|
||||
{processing.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-slate-300 mb-2 flex items-center gap-2">
|
||||
<Loader2 size={14} className="animate-spin text-yellow-400" /> Em processamento
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{processing.map((m) => (
|
||||
<Card key={m.id} className="cursor-pointer hover:border-slate-500" onClick={() => navigate(`/meetings/${m.id}`)}>
|
||||
<CardContent className="flex items-center justify-between p-3">
|
||||
<span className="text-white text-sm font-medium">{m.title || 'Reunião sem título'}</span>
|
||||
<StatusBadge status={m.status} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent meetings */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-sm font-semibold text-slate-300">Reuniões recentes</h2>
|
||||
<button onClick={() => navigate('/history')} className="text-green-400 text-xs hover:underline">Ver todas</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8"><Loader2 size={24} className="animate-spin text-slate-400" /></div>
|
||||
) : recent.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8 text-slate-400">
|
||||
<Mic size={32} className="mx-auto mb-2 opacity-30" />
|
||||
<p>Nenhuma reunião ainda.</p>
|
||||
<p className="text-xs mt-1">Grave ou envie seu primeiro áudio!</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{recent.map((m) => (
|
||||
<Card key={m.id} className="cursor-pointer hover:border-slate-500" onClick={() => navigate(`/meetings/${m.id}`)}>
|
||||
<CardContent className="flex items-center justify-between p-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-medium truncate">{m.title || 'Reunião sem título'}</p>
|
||||
<p className="text-slate-400 text-xs">{formatDistanceToNow(m.created_at)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
{m.status === 'completed' && <CheckCircle size={16} className="text-green-400" />}
|
||||
{m.status === 'error' && <AlertCircle size={16} className="text-red-400" />}
|
||||
{(m.status === 'processing' || m.status === 'uploading') && <Clock size={16} className="text-yellow-400" />}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,522 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { ArrowLeft, Loader2, Edit2, Check, X, Play, Pause, Volume2 } from 'lucide-react'
|
||||
import { meetingsApi } from '@/api/meetings'
|
||||
import api from '@/api/axios'
|
||||
import { useMeetingsStore } from '@/store/meetingsStore'
|
||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { StatusBadge } from '@/components/StatusBadge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import type { Meeting, TranscriptSegment, Minutes, SentimentData, PsychologicalData, CommunicationData, SummaryData } from '@/types'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
|
||||
type Tab = 'transcript' | 'minutes' | 'sentiment' | 'psychological' | 'communication' | 'summary'
|
||||
|
||||
const SPEAKER_COLORS = ['#4ade80', '#60a5fa', '#f472b6', '#fb923c', '#a78bfa', '#34d399']
|
||||
const TERMINAL = ['completed', 'error']
|
||||
|
||||
export function MeetingDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const { meetings, updateMeeting } = useMeetingsStore()
|
||||
const [meeting, setMeeting] = useState<Meeting | null>(meetings.find((m) => m.id === id) ?? null)
|
||||
const [tab, setTab] = useState<Tab>('transcript')
|
||||
const [minutes, setMinutes] = useState<Minutes | null>(null)
|
||||
const [sentiment, setSentiment] = useState<SentimentData | null>(null)
|
||||
const [psychological, setPsychological] = useState<PsychologicalData | null>(null)
|
||||
const [communication, setCommunication] = useState<CommunicationData | null>(null)
|
||||
const [summary, setSummary] = useState<SummaryData | null>(null)
|
||||
const [loadingTab, setLoadingTab] = useState(false)
|
||||
const [editingLabel, setEditingLabel] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [audioUrl, setAudioUrl] = useState<string | null>(null)
|
||||
const [audioLoading, setAudioLoading] = useState(false)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||||
|
||||
const [enrollNames, setEnrollNames] = useState<Record<string, string>>({})
|
||||
const [enrollingLabel, setEnrollingLabel] = useState<string | null>(null)
|
||||
const [enrollErrors, setEnrollErrors] = useState<Record<string, string>>({})
|
||||
const [enrollSuccess, setEnrollSuccess] = useState<Record<string, boolean>>({})
|
||||
|
||||
const isTerminal = TERMINAL.includes(meeting?.status ?? '')
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
meetingsApi.get(id).then(({ data }) => {
|
||||
setMeeting(data)
|
||||
updateMeeting(id, data)
|
||||
if (data.status === 'completed') setTab('transcript')
|
||||
})
|
||||
}, [id, updateMeeting])
|
||||
|
||||
const onWsMessage = useCallback((data: unknown) => {
|
||||
const msg = data as { type: string; status?: string; progress?: number }
|
||||
if (msg.type === 'status_update' && msg.status) {
|
||||
setMeeting((m) => m ? { ...m, status: msg.status as Meeting['status'], progress: msg.progress } : m)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useWebSocket(meeting?.status === 'processing' ? (id ?? null) : null, onWsMessage)
|
||||
|
||||
useEffect(() => {
|
||||
if (!id || isTerminal) return
|
||||
const iv = setInterval(async () => {
|
||||
try {
|
||||
const { data } = await meetingsApi.get(id)
|
||||
setMeeting(data)
|
||||
updateMeeting(id, data)
|
||||
if (TERMINAL.includes(data.status)) clearInterval(iv)
|
||||
} catch { /* ignore */ }
|
||||
}, 4000)
|
||||
return () => clearInterval(iv)
|
||||
}, [id, isTerminal, updateMeeting])
|
||||
|
||||
async function loadTab(t: Tab) {
|
||||
setTab(t)
|
||||
setLoadingTab(true)
|
||||
try {
|
||||
if (t === 'minutes' && !minutes) {
|
||||
const { data } = await meetingsApi.getAnalysis(id!, 'minutes')
|
||||
setMinutes(data.content as Minutes)
|
||||
} else if (t === 'sentiment' && !sentiment) {
|
||||
const { data } = await meetingsApi.getAnalysis(id!, 'sentiment')
|
||||
setSentiment(data.content as SentimentData)
|
||||
} else if (t === 'psychological' && !psychological) {
|
||||
const { data } = await meetingsApi.getAnalysis(id!, 'psychological')
|
||||
setPsychological(data.content as PsychologicalData)
|
||||
} else if (t === 'communication' && !communication) {
|
||||
const { data } = await meetingsApi.getAnalysis(id!, 'communication')
|
||||
setCommunication(data.content as CommunicationData)
|
||||
} else if (t === 'summary' && !summary) {
|
||||
const { data } = await meetingsApi.getAnalysis(id!, 'summary')
|
||||
setSummary(data.content as SummaryData)
|
||||
}
|
||||
} catch { /* analysis not ready */ } finally {
|
||||
setLoadingTab(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAudio() {
|
||||
if (audioUrl) return
|
||||
setAudioLoading(true)
|
||||
try {
|
||||
const resp = await api.get(`/meetings/${id}/audio`, { responseType: 'blob' })
|
||||
const url = URL.createObjectURL(resp.data)
|
||||
setAudioUrl(url)
|
||||
if (audioRef.current) audioRef.current.src = url
|
||||
} catch { /* no audio */ } finally {
|
||||
setAudioLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
function togglePlay() {
|
||||
if (!audioRef.current) return
|
||||
if (isPlaying) { audioRef.current.pause(); setIsPlaying(false) }
|
||||
else { audioRef.current.play(); setIsPlaying(true) }
|
||||
}
|
||||
|
||||
async function saveLabel(label: string) {
|
||||
if (!id || !editName.trim()) return
|
||||
await meetingsApi.relabelParticipant(id, label, editName.trim())
|
||||
setMeeting((m) => m ? { ...m } : m)
|
||||
setEditingLabel(null)
|
||||
}
|
||||
|
||||
function getSpeakerColor(speaker: string): string {
|
||||
const segs = meeting?.transcription?.segments ?? []
|
||||
const speakers = [...new Set(segs.map((s) => s.speaker))]
|
||||
return SPEAKER_COLORS[speakers.indexOf(speaker) % SPEAKER_COLORS.length]
|
||||
}
|
||||
|
||||
const sentimentColor = (s?: string) =>
|
||||
s === 'positive' ? '#4ade80' : s === 'negative' ? '#f87171' : '#94a3b8'
|
||||
|
||||
const tabs: { key: Tab; label: string }[] = [
|
||||
{ key: 'transcript', label: 'Transcricao' },
|
||||
{ key: 'summary', label: 'Resumo' },
|
||||
{ key: 'minutes', label: 'Ata' },
|
||||
{ key: 'sentiment', label: 'Sentimento' },
|
||||
{ key: 'psychological', label: 'Perfil' },
|
||||
{ key: 'communication', label: 'Comunicacao' },
|
||||
]
|
||||
|
||||
const segments: TranscriptSegment[] = meeting?.transcription?.segments ?? []
|
||||
const unidentifiedSpeakers = [...new Set(segments.filter(s => /^SPEAKER_\d+$/.test(s.speaker)).map(s => s.speaker))].sort()
|
||||
|
||||
async function handleEnroll(label: string) {
|
||||
const name = (enrollNames[label] || '').trim()
|
||||
if (!name || !id) return
|
||||
setEnrollingLabel(label)
|
||||
setEnrollErrors(e => ({ ...e, [label]: '' }))
|
||||
try {
|
||||
await meetingsApi.enrollSpeaker(id, label, name)
|
||||
setEnrollSuccess(s => ({ ...s, [label]: true }))
|
||||
setMeeting(m => {
|
||||
if (!m?.transcription?.segments) return m
|
||||
return {
|
||||
...m,
|
||||
transcription: {
|
||||
...m.transcription,
|
||||
segments: m.transcription.segments.map((seg: TranscriptSegment) =>
|
||||
seg.speaker === label ? { ...seg, speaker: name } : seg
|
||||
),
|
||||
},
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
setEnrollErrors(e => ({ ...e, [label]: 'Erro ao salvar. Tente novamente.' }))
|
||||
} finally {
|
||||
setEnrollingLabel(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="p-4 max-w-2xl mx-auto space-y-4 pb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => navigate(-1)} className="text-slate-400 hover:text-white"><ArrowLeft size={20} /></button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-lg font-bold text-white truncate">{meeting?.title || 'Reuniao sem titulo'}</h1>
|
||||
{meeting && <StatusBadge status={meeting.status} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{meeting?.status === 'completed' && (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<Volume2 size={16} className="text-slate-400 shrink-0" />
|
||||
{!audioUrl && !audioLoading && (
|
||||
<button onClick={loadAudio} className="text-sm text-green-400 hover:text-green-300">
|
||||
Carregar audio da reuniao
|
||||
</button>
|
||||
)}
|
||||
{audioLoading && <span className="text-slate-400 text-sm flex items-center gap-2"><Loader2 size={14} className="animate-spin" /> Carregando...</span>}
|
||||
{audioUrl && (
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<button onClick={togglePlay} className="w-8 h-8 rounded-full bg-green-600 hover:bg-green-500 flex items-center justify-center shrink-0">
|
||||
{isPlaying ? <Pause size={14} /> : <Play size={14} />}
|
||||
</button>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioUrl}
|
||||
onEnded={() => setIsPlaying(false)}
|
||||
onPause={() => setIsPlaying(false)}
|
||||
onPlay={() => setIsPlaying(true)}
|
||||
controls
|
||||
className="flex-1 h-8"
|
||||
style={{ accentColor: '#4ade80' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isTerminal && meeting?.status !== undefined && (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center py-8 gap-3">
|
||||
<Loader2 size={32} className="animate-spin text-yellow-400" />
|
||||
<p className="text-slate-300 text-sm font-medium">
|
||||
{meeting.status === 'receiving_chunks' && 'Recebendo audio...'}
|
||||
{meeting.status === 'uploading' && 'Enviando para processamento...'}
|
||||
{meeting.status === 'processing' && 'Iniciando processamento...'}
|
||||
{meeting.status === 'transcribing' && 'Transcrevendo com Whisper...'}
|
||||
{meeting.status === 'analyzing' && 'Analisando com IA...'}
|
||||
</p>
|
||||
<p className="text-slate-500 text-xs">A pagina atualiza automaticamente</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{meeting?.status === 'completed' && (
|
||||
<>
|
||||
{unidentifiedSpeakers.length > 0 && (
|
||||
<Card>
|
||||
<CardContent className="py-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-lg">🎤</span>
|
||||
<div>
|
||||
<p className="text-white text-sm font-semibold">Identificar participantes</p>
|
||||
<p className="text-slate-400 text-xs">Para reconhecimento automático em reuniões futuras</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{unidentifiedSpeakers.map(label => (
|
||||
<div key={label} className="flex items-center gap-2">
|
||||
<span className="text-xs font-mono text-slate-400 w-24 shrink-0">{label}</span>
|
||||
<Input
|
||||
value={enrollNames[label] || ''}
|
||||
onChange={e => setEnrollNames(n => ({ ...n, [label]: e.target.value }))}
|
||||
onKeyDown={e => e.key === 'Enter' && handleEnroll(label)}
|
||||
placeholder="Nome da pessoa"
|
||||
className="h-7 text-xs flex-1"
|
||||
disabled={!!enrollSuccess[label]}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs shrink-0"
|
||||
disabled={enrollingLabel === label || !!enrollSuccess[label]}
|
||||
onClick={() => handleEnroll(label)}
|
||||
>
|
||||
{enrollingLabel === label ? <Loader2 size={12} className="animate-spin" /> : enrollSuccess[label] ? <Check size={12} /> : 'Salvar'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{Object.entries(enrollErrors).filter(([, v]) => v).map(([k, v]) => (
|
||||
<p key={k} className="text-red-400 text-xs mt-1">{k}: {v}</p>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1 overflow-x-auto pb-1">
|
||||
{tabs.map((t) => (
|
||||
<button key={t.key} onClick={() => loadTab(t.key)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium whitespace-nowrap transition-colors ${tab === t.key ? 'bg-green-700 text-white' : 'text-slate-400 hover:text-slate-200 hover:bg-slate-700'}`}>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loadingTab && <div className="flex justify-center py-8"><Loader2 size={20} className="animate-spin text-slate-400" /></div>}
|
||||
|
||||
{tab === 'transcript' && !loadingTab && (
|
||||
<div className="space-y-2">
|
||||
{segments.length > 0 ? segments.map((seg, i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-2 h-2 rounded-full mt-2 shrink-0" style={{ background: getSpeakerColor(seg.speaker) }} />
|
||||
<div className="w-px flex-1 bg-slate-700 mt-1" />
|
||||
</div>
|
||||
<div className="flex-1 pb-3">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-xs font-semibold" style={{ color: getSpeakerColor(seg.speaker) }}>{seg.speaker}</span>
|
||||
<span className="text-slate-500 text-xs">{Math.floor(seg.start / 60)}:{String(Math.floor(seg.start % 60)).padStart(2, '0')}</span>
|
||||
{editingLabel === seg.speaker ? (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<Input value={editName} onChange={(e) => setEditName(e.target.value)} className="h-5 px-1 text-xs w-24" />
|
||||
<button onClick={() => saveLabel(seg.speaker)} className="text-green-400"><Check size={12} /></button>
|
||||
<button onClick={() => setEditingLabel(null)} className="text-red-400"><X size={12} /></button>
|
||||
</div>
|
||||
) : (
|
||||
<button onClick={() => { setEditingLabel(seg.speaker); setEditName(seg.speaker) }} className="text-slate-500 hover:text-slate-300"><Edit2 size={10} /></button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-slate-200 text-sm">{seg.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
)) : <p className="text-slate-400 text-sm text-center py-8">Transcricao nao disponivel.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'summary' && !loadingTab && (
|
||||
<div className="space-y-3">
|
||||
{summary ? (
|
||||
<>
|
||||
{summary.summary && <Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Resumo</h3><p className="text-slate-200 text-sm leading-relaxed">{summary.summary}</p></CardContent></Card>}
|
||||
{summary.key_points && summary.key_points.length > 0 && (
|
||||
<Card><CardContent>
|
||||
<h3 className="text-green-400 text-sm font-semibold mb-2">Pontos-chave</h3>
|
||||
<ul className="space-y-1">{summary.key_points.map((p, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span className="text-green-400">•</span>{p}</li>)}</ul>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
<div className="flex gap-3">
|
||||
{summary.duration_minutes != null && <Card className="flex-1"><CardContent className="text-center py-3"><p className="text-2xl font-bold text-white">{summary.duration_minutes}'</p><p className="text-slate-400 text-xs">duracao</p></CardContent></Card>}
|
||||
{summary.total_participants != null && <Card className="flex-1"><CardContent className="text-center py-3"><p className="text-2xl font-bold text-white">{summary.total_participants}</p><p className="text-slate-400 text-xs">participantes</p></CardContent></Card>}
|
||||
</div>
|
||||
</>
|
||||
) : <p className="text-slate-400 text-sm text-center py-8">Resumo nao disponivel.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'minutes' && !loadingTab && (
|
||||
<div className="space-y-3">
|
||||
{minutes ? (
|
||||
<>
|
||||
{minutes.agenda && minutes.agenda.length > 0 && (
|
||||
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Pauta</h3><ul className="space-y-1">{minutes.agenda.map((a, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span className="text-slate-400">{i+1}.</span>{a}</li>)}</ul></CardContent></Card>
|
||||
)}
|
||||
{minutes.decisions && minutes.decisions.length > 0 && (
|
||||
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Decisoes</h3><ul className="space-y-1">{minutes.decisions.map((d, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span>•</span>{d}</li>)}</ul></CardContent></Card>
|
||||
)}
|
||||
{minutes.action_items && minutes.action_items.length > 0 && (
|
||||
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Acoes</h3>
|
||||
<div className="space-y-2">{minutes.action_items.map((a, i) => (
|
||||
<div key={i} className="text-sm border-l-2 border-slate-700 pl-3">
|
||||
<p className="text-slate-200">{a.description}</p>
|
||||
{a.responsible && <p className="text-slate-400 text-xs">→ {a.responsible}</p>}
|
||||
{a.deadline && a.deadline !== 'A definir' && <p className="text-slate-500 text-xs">{a.deadline}</p>}
|
||||
</div>
|
||||
))}</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
{minutes.next_steps && minutes.next_steps.length > 0 && (
|
||||
<Card><CardContent><h3 className="text-blue-400 text-sm font-semibold mb-2">Proximos passos</h3><ul className="space-y-1">{minutes.next_steps.map((s, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span>→</span>{s}</li>)}</ul></CardContent></Card>
|
||||
)}
|
||||
{minutes.open_points && minutes.open_points.length > 0 && (
|
||||
<Card><CardContent><h3 className="text-yellow-400 text-sm font-semibold mb-2">Pontos em aberto</h3><ul className="space-y-1">{minutes.open_points.map((s, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span>•</span>{s}</li>)}</ul></CardContent></Card>
|
||||
)}
|
||||
</>
|
||||
) : <p className="text-slate-400 text-sm text-center py-8">Ata nao disponivel.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'sentiment' && !loadingTab && (
|
||||
<div className="space-y-3">
|
||||
{sentiment ? (
|
||||
<>
|
||||
{sentiment.overall_sentiment && (
|
||||
<Card><CardContent className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 rounded-full" style={{ background: sentimentColor(sentiment.overall_sentiment) }} />
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">Sentimento geral</p>
|
||||
<p className="text-slate-400 text-xs capitalize">{sentiment.overall_sentiment}</p>
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
{sentiment.participation_metrics && sentiment.participation_metrics.length > 0 && (
|
||||
<Card><CardContent>
|
||||
<h3 className="text-green-400 text-sm font-semibold mb-3">Participacao</h3>
|
||||
<ResponsiveContainer width="100%" height={140}>
|
||||
<BarChart data={sentiment.participation_metrics}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||
<XAxis dataKey="speaker" tick={{ fill: '#94a3b8', fontSize: 10 }} />
|
||||
<YAxis tick={{ fill: '#94a3b8', fontSize: 10 }} unit="%" />
|
||||
<Tooltip contentStyle={{ background: '#1e293b', border: 'none', borderRadius: 8 }} formatter={(v) => [`${v}%`, 'Participacao']} />
|
||||
<Bar dataKey="talk_percentage" fill="#4ade80" radius={[4,4,0,0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
{sentiment.participants_sentiment && sentiment.participants_sentiment.length > 0 && (
|
||||
<Card><CardContent>
|
||||
<h3 className="text-green-400 text-sm font-semibold mb-2">Por participante</h3>
|
||||
<div className="space-y-3">{sentiment.participants_sentiment.map((p, i) => (
|
||||
<div key={i}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<div className="w-2 h-2 rounded-full" style={{ background: sentimentColor(p.sentiment) }} />
|
||||
<span className="text-slate-300 text-sm font-medium">{p.speaker}</span>
|
||||
<span className="text-slate-500 text-xs capitalize">({p.sentiment})</span>
|
||||
</div>
|
||||
<p className="text-slate-400 text-xs leading-relaxed pl-4">{p.details}</p>
|
||||
</div>
|
||||
))}</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
{sentiment.timeline && sentiment.timeline.length > 0 && (
|
||||
<Card><CardContent>
|
||||
<h3 className="text-green-400 text-sm font-semibold mb-2">Linha do tempo</h3>
|
||||
<div className="space-y-2">{sentiment.timeline.map((t, i) => (
|
||||
<div key={i} className="flex gap-3 text-sm">
|
||||
<span className="text-slate-500 text-xs whitespace-nowrap shrink-0">{t.time_range}</span>
|
||||
<div>
|
||||
<span className="font-medium capitalize" style={{ color: sentimentColor(t.sentiment) }}>{t.sentiment}</span>
|
||||
{t.notes && <p className="text-slate-400 text-xs mt-0.5">{t.notes}</p>}
|
||||
</div>
|
||||
</div>
|
||||
))}</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
{sentiment.tension_moments && sentiment.tension_moments.length > 0 && (
|
||||
<Card><CardContent><h3 className="text-red-400 text-sm font-semibold mb-2">Momentos de tensao</h3><ul className="space-y-1">{sentiment.tension_moments.map((t, i) => <li key={i} className="text-slate-300 text-sm flex gap-2"><span>•</span>{t}</li>)}</ul></CardContent></Card>
|
||||
)}
|
||||
{sentiment.consensus_moments && sentiment.consensus_moments.length > 0 && (
|
||||
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Momentos de consenso</h3><ul className="space-y-1">{sentiment.consensus_moments.map((t, i) => <li key={i} className="text-slate-300 text-sm flex gap-2"><span>•</span>{t}</li>)}</ul></CardContent></Card>
|
||||
)}
|
||||
</>
|
||||
) : <p className="text-slate-400 text-sm text-center py-8">Analise de sentimento nao disponivel.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'psychological' && !loadingTab && (
|
||||
<div className="space-y-3">
|
||||
{psychological ? (
|
||||
<>
|
||||
{psychological.group_dynamics && (
|
||||
<Card><CardContent><h3 className="text-purple-400 text-sm font-semibold mb-2">Dinamica do grupo</h3><p className="text-slate-200 text-sm leading-relaxed">{psychological.group_dynamics}</p></CardContent></Card>
|
||||
)}
|
||||
{psychological.participants_profiles && psychological.participants_profiles.length > 0 && psychological.participants_profiles.map((p, i) => (
|
||||
<Card key={i}><CardContent>
|
||||
<h3 className="text-purple-400 text-sm font-semibold mb-2">{p.name || p.speaker}</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
{p.communication_style && <div><span className="text-slate-400 text-xs">Estilo de comunicacao:</span><p className="text-slate-200">{p.communication_style}</p></div>}
|
||||
{p.leadership_tendencies && <div><span className="text-slate-400 text-xs">Lideranca:</span><p className="text-slate-200">{p.leadership_tendencies}</p></div>}
|
||||
{p.power_dynamics_role && <div><span className="text-slate-400 text-xs">Papel na dinamica:</span><p className="text-slate-200">{p.power_dynamics_role}</p></div>}
|
||||
</div>
|
||||
</CardContent></Card>
|
||||
))}
|
||||
{psychological.key_observations && psychological.key_observations.length > 0 && (
|
||||
<Card><CardContent><h3 className="text-purple-400 text-sm font-semibold mb-2">Observacoes</h3><ul className="space-y-1">{psychological.key_observations.map((o, i) => <li key={i} className="text-slate-200 text-sm flex gap-2"><span>•</span>{o}</li>)}</ul></CardContent></Card>
|
||||
)}
|
||||
</>
|
||||
) : <p className="text-slate-400 text-sm text-center py-8">Perfil psicologico nao disponivel.</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'communication' && !loadingTab && (
|
||||
<div className="space-y-3">
|
||||
{communication ? (
|
||||
<>
|
||||
{communication.overall_score != null && (
|
||||
<Card><CardContent className="text-center py-4">
|
||||
<div className="text-5xl font-bold text-white mb-1">{communication.overall_score}<span className="text-xl text-slate-400">/10</span></div>
|
||||
<p className="text-slate-400 text-sm">Score de comunicacao</p>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
{communication.general_feedback && (
|
||||
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Feedback geral</h3><p className="text-slate-200 text-sm leading-relaxed">{communication.general_feedback}</p></CardContent></Card>
|
||||
)}
|
||||
{communication.dimensions && (
|
||||
<Card><CardContent>
|
||||
<h3 className="text-green-400 text-sm font-semibold mb-3">Dimensoes</h3>
|
||||
<div className="space-y-3">{Object.entries(communication.dimensions).map(([dim, val]) => (
|
||||
<div key={dim}>
|
||||
<div className="flex justify-between text-xs text-slate-300 mb-1">
|
||||
<span className="capitalize">{dim.replace(/_/g, ' ')}</span>
|
||||
<span>{val.score}/10</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-slate-700 rounded-full"><div className="h-full bg-green-500 rounded-full transition-all" style={{ width: `${val.score * 10}%` }} /></div>
|
||||
{val.notes && <p className="text-slate-500 text-xs mt-1">{val.notes}</p>}
|
||||
</div>
|
||||
))}</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
{communication.strengths && communication.strengths.length > 0 && (
|
||||
<Card><CardContent><h3 className="text-green-400 text-sm font-semibold mb-2">Pontos fortes</h3>
|
||||
<div className="space-y-2">{communication.strengths.map((s, i) => (
|
||||
<div key={i}><p className="text-slate-200 text-sm">{s.description}</p>{s.example && <p className="text-slate-500 text-xs mt-0.5 pl-4">"{s.example}"</p>}</div>
|
||||
))}</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
{communication.improvement_areas && communication.improvement_areas.length > 0 && (
|
||||
<Card><CardContent><h3 className="text-yellow-400 text-sm font-semibold mb-2">Melhorias</h3>
|
||||
<div className="space-y-2">{communication.improvement_areas.map((a, i) => (
|
||||
<div key={i}><p className="text-slate-200 text-sm">{a.description}</p>{a.suggestion && <p className="text-slate-500 text-xs mt-0.5 pl-4">→ {a.suggestion}</p>}</div>
|
||||
))}</div>
|
||||
</CardContent></Card>
|
||||
)}
|
||||
</>
|
||||
) : <p className="text-slate-400 text-sm text-center py-8">Analise de comunicacao nao disponivel.</p>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{meeting?.status === 'error' && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<p className="text-red-400 font-medium">Erro ao processar reuniao</p>
|
||||
{meeting.error_message && <p className="text-slate-400 text-sm mt-1">{meeting.error_message}</p>}
|
||||
<Button variant="outline" onClick={() => navigate(-1)} className="mt-4">Voltar</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { Plus, Trash2, Mic, Loader2 } from 'lucide-react'
|
||||
import { participantsApi } from '@/api/participants'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { Participant } from '@/types'
|
||||
|
||||
export function Participants() {
|
||||
const [participants, setParticipants] = useState<Participant[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [name, setName] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
participantsApi.list().then(({ data }) => setParticipants(data)).finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
async function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
setAdding(true)
|
||||
try {
|
||||
const { data } = await participantsApi.create(name.trim(), email || undefined)
|
||||
setParticipants((p) => [...p, data])
|
||||
setName('')
|
||||
setEmail('')
|
||||
setShowForm(false)
|
||||
} finally { setAdding(false) }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!confirm('Excluir perfil vocal?')) return
|
||||
await participantsApi.delete(id)
|
||||
setParticipants((p) => p.filter((x) => x.id !== id))
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="p-4 max-w-2xl mx-auto space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-bold text-white">Participantes</h1>
|
||||
<Button size="sm" onClick={() => setShowForm((v) => !v)}>
|
||||
<Plus size={16} /> Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<form onSubmit={handleAdd} className="space-y-3">
|
||||
<Input placeholder="Nome *" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
<Input type="email" placeholder="E-mail (opcional)" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={adding} className="flex-1">{adding ? 'Salvando...' : 'Salvar'}</Button>
|
||||
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>Cancelar</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Loader2 size={24} className="animate-spin text-slate-400" /></div>
|
||||
) : participants.length === 0 ? (
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Mic size={32} className="mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">Nenhum perfil vocal cadastrado.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{participants.map((p) => (
|
||||
<Card key={p.id}>
|
||||
<CardContent className="flex items-center justify-between p-3">
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">{p.name}</p>
|
||||
{p.email && <p className="text-slate-400 text-xs">{p.email}</p>}
|
||||
<p className="text-xs mt-0.5">{p.voice_enrolled ? <span className="text-green-400">✓ Voz cadastrada</span> : <span className="text-slate-500">Voz não cadastrada</span>}</p>
|
||||
</div>
|
||||
<button onClick={() => handleDelete(p.id)} className="text-slate-500 hover:text-red-400 p-1"><Trash2 size={16} /></button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Mic, Square, Loader2, Wifi, WifiOff } from 'lucide-react'
|
||||
import { meetingsApi } from '@/api/meetings'
|
||||
import { db } from '@/services/db'
|
||||
import { useWakeLock } from '@/hooks/useWakeLock'
|
||||
import { cn } from '@/utils/cn'
|
||||
|
||||
type RecordingStatus = 'idle' | 'recording' | 'finalizing' | 'error'
|
||||
|
||||
function getSupportedMimeType(): string {
|
||||
const types = ['audio/webm;codecs=opus', 'audio/webm', 'audio/ogg;codecs=opus', 'audio/mp4']
|
||||
for (const type of types) {
|
||||
if (MediaRecorder.isTypeSupported(type)) return type
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
const CHUNK_INTERVAL_MS = 30_000
|
||||
|
||||
export function Recording() {
|
||||
const navigate = useNavigate()
|
||||
const { acquire: acquireWakeLock, release: releaseWakeLock } = useWakeLock()
|
||||
|
||||
const [status, setStatus] = useState<RecordingStatus>('idle')
|
||||
const [title, setTitle] = useState('')
|
||||
const [elapsed, setElapsed] = useState(0)
|
||||
const [chunkCount, setChunkCount] = useState(0)
|
||||
const [uploadedCount, setUploadedCount] = useState(0)
|
||||
const [isOnline, setIsOnline] = useState(navigator.onLine)
|
||||
const [errorMsg, setErrorMsg] = useState('')
|
||||
|
||||
const meetingIdRef = useRef<string | null>(null)
|
||||
const recorderRef = useRef<MediaRecorder | null>(null)
|
||||
const streamRef = useRef<MediaStream | null>(null)
|
||||
const chunkIndexRef = useRef(0)
|
||||
const elapsedIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const retryIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||
const mimeTypeRef = useRef('')
|
||||
|
||||
// VU meter
|
||||
const [vuLevel, setVuLevel] = useState(0) // 0..1
|
||||
const audioCtxRef = useRef<AudioContext | null>(null)
|
||||
const analyserRef = useRef<AnalyserNode | null>(null)
|
||||
const vuRafRef = useRef<number | null>(null)
|
||||
|
||||
const startVU = (stream: MediaStream) => {
|
||||
const ctx = new AudioContext()
|
||||
const analyser = ctx.createAnalyser()
|
||||
analyser.fftSize = 256
|
||||
ctx.createMediaStreamSource(stream).connect(analyser)
|
||||
audioCtxRef.current = ctx
|
||||
analyserRef.current = analyser
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount)
|
||||
const tick = () => {
|
||||
analyser.getByteTimeDomainData(buf)
|
||||
// RMS
|
||||
let sum = 0
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
const v = (buf[i] - 128) / 128
|
||||
sum += v * v
|
||||
}
|
||||
setVuLevel(Math.min(1, Math.sqrt(sum / buf.length) * 6))
|
||||
vuRafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
vuRafRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
const stopVU = () => {
|
||||
if (vuRafRef.current) cancelAnimationFrame(vuRafRef.current)
|
||||
analyserRef.current?.disconnect()
|
||||
audioCtxRef.current?.close()
|
||||
audioCtxRef.current = null
|
||||
analyserRef.current = null
|
||||
setVuLevel(0)
|
||||
}
|
||||
|
||||
const retryPendingChunks = useCallback(async () => {
|
||||
if (!navigator.onLine) return
|
||||
try {
|
||||
const pending = await db.getPendingChunks()
|
||||
for (const chunk of pending) {
|
||||
try {
|
||||
await db.updateChunkStatus(chunk.id!, 'uploading')
|
||||
await meetingsApi.uploadChunk(chunk.meetingId, chunk.chunkIndex, chunk.blob, chunk.mimeType)
|
||||
await db.deleteChunk(chunk.id!)
|
||||
setUploadedCount(c => c + 1)
|
||||
} catch {
|
||||
await db.updateChunkStatus(chunk.id!, 'pending')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Recording] retryPendingChunks error', e)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onOnline = () => { setIsOnline(true); retryPendingChunks() }
|
||||
const onOffline = () => setIsOnline(false)
|
||||
window.addEventListener('online', onOnline)
|
||||
window.addEventListener('offline', onOffline)
|
||||
return () => {
|
||||
window.removeEventListener('online', onOnline)
|
||||
window.removeEventListener('offline', onOffline)
|
||||
}
|
||||
}, [retryPendingChunks])
|
||||
|
||||
// On mount: resume any pending chunks from previous sessions
|
||||
useEffect(() => { retryPendingChunks() }, [retryPendingChunks])
|
||||
|
||||
const uploadChunkNow = useCallback(async (
|
||||
meetingId: string, chunkIndex: number, blob: Blob, mimeType: string, dbId?: number
|
||||
) => {
|
||||
try {
|
||||
await meetingsApi.uploadChunk(meetingId, chunkIndex, blob, mimeType)
|
||||
if (dbId != null && dbId >= 0) await db.deleteChunk(dbId)
|
||||
setUploadedCount(c => c + 1)
|
||||
} catch {
|
||||
// Keep in IndexedDB for retry - already saved
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
setErrorMsg('')
|
||||
// Request audio with quality constraints for transcription
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
channelCount: 1,
|
||||
}
|
||||
})
|
||||
streamRef.current = stream
|
||||
const mimeType = getSupportedMimeType()
|
||||
mimeTypeRef.current = mimeType
|
||||
|
||||
const meeting = await meetingsApi.startMeeting(title || undefined)
|
||||
meetingIdRef.current = meeting.id
|
||||
chunkIndexRef.current = 0
|
||||
setChunkCount(0)
|
||||
setUploadedCount(0)
|
||||
setElapsed(0)
|
||||
|
||||
acquireWakeLock()
|
||||
setStatus('recording')
|
||||
startVU(stream)
|
||||
|
||||
// Use timeslice: single continuous MediaRecorder session, no stop/start gaps
|
||||
const recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined)
|
||||
recorderRef.current = recorder
|
||||
|
||||
recorder.ondataavailable = async (e) => {
|
||||
if (e.data.size === 0) return
|
||||
const currentIndex = chunkIndexRef.current++
|
||||
const meetId = meetingIdRef.current!
|
||||
setChunkCount(n => n + 1)
|
||||
|
||||
const blob = new Blob([e.data], { type: mimeType })
|
||||
const dbId = await db.saveChunk({
|
||||
meetingId: meetId, chunkIndex: currentIndex, blob, mimeType,
|
||||
status: 'pending', createdAt: Date.now()
|
||||
})
|
||||
await uploadChunkNow(meetId, currentIndex, blob, mimeType, dbId)
|
||||
}
|
||||
|
||||
// Fire ondataavailable every CHUNK_INTERVAL_MS — no gaps, continuous stream
|
||||
recorder.start(CHUNK_INTERVAL_MS)
|
||||
|
||||
retryIntervalRef.current = setInterval(() => { retryPendingChunks() }, CHUNK_INTERVAL_MS)
|
||||
elapsedIntervalRef.current = setInterval(() => { setElapsed(s => s + 1) }, 1000)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Erro ao iniciar gravação'
|
||||
setErrorMsg(msg)
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
const stopRecording = async () => {
|
||||
if (!meetingIdRef.current) return
|
||||
setStatus('finalizing')
|
||||
|
||||
if (retryIntervalRef.current) clearInterval(retryIntervalRef.current)
|
||||
if (elapsedIntervalRef.current) clearInterval(elapsedIntervalRef.current)
|
||||
stopVU()
|
||||
|
||||
// Stop recorder — triggers final ondataavailable then onstop
|
||||
await new Promise<void>((resolve) => {
|
||||
const rec = recorderRef.current
|
||||
if (!rec || rec.state === 'inactive') { resolve(); return }
|
||||
rec.onstop = () => resolve()
|
||||
rec.stop()
|
||||
})
|
||||
|
||||
streamRef.current?.getTracks().forEach(t => t.stop())
|
||||
releaseWakeLock()
|
||||
|
||||
// Wait for pending IndexedDB chunks (max 60s)
|
||||
const deadline = Date.now() + 60_000
|
||||
while (Date.now() < deadline) {
|
||||
const pending = await db.getPendingChunks(meetingIdRef.current!)
|
||||
if (pending.length === 0) break
|
||||
await retryPendingChunks()
|
||||
await new Promise(r => setTimeout(r, 2000))
|
||||
}
|
||||
|
||||
try {
|
||||
const meeting = await meetingsApi.finalizeMeeting(meetingIdRef.current!, title || undefined)
|
||||
navigate(`/meetings/${meeting.id}`)
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Erro ao finalizar reunião'
|
||||
setErrorMsg(`${msg}. Os segmentos estão salvos e serão reenviados.`)
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
const formatTime = (secs: number) => {
|
||||
const h = Math.floor(secs / 3600)
|
||||
const m = Math.floor((secs % 3600) / 60)
|
||||
const s = secs % 60
|
||||
return h > 0
|
||||
? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
: `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-950 text-white flex flex-col items-center justify-center p-6 gap-6">
|
||||
{/* Online indicator */}
|
||||
<div className={cn(
|
||||
'flex items-center gap-2 text-sm px-3 py-1 rounded-full',
|
||||
isOnline ? 'bg-green-900/40 text-green-400' : 'bg-yellow-900/40 text-yellow-400'
|
||||
)}>
|
||||
{isOnline ? <Wifi className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
|
||||
{isOnline ? 'Online' : 'Sem internet — gravando localmente'}
|
||||
</div>
|
||||
|
||||
{/* Title input */}
|
||||
{status === 'idle' && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Título da reunião (opcional)"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
className="w-full max-w-sm bg-slate-800 border border-slate-700 rounded-xl px-4 py-3 text-white placeholder-slate-500 text-center focus:outline-none focus:border-green-500"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Timer */}
|
||||
{status === 'recording' && (
|
||||
<div className="text-center">
|
||||
<div className="text-5xl font-mono font-bold tabular-nums">{formatTime(elapsed)}</div>
|
||||
<div className="text-slate-400 text-sm mt-2">
|
||||
Segmento {chunkCount + 1} • {uploadedCount} enviados
|
||||
{!isOnline && <span className="text-yellow-400"> • aguardando conexão</span>}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Idle: start button */}
|
||||
{status === 'idle' && (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="w-24 h-24 rounded-full bg-green-600 hover:bg-green-500 flex items-center justify-center shadow-lg shadow-green-900/50 transition-all active:scale-95"
|
||||
>
|
||||
<Mic className="w-10 h-10" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Recording: pulsing mic + VU + stop button */}
|
||||
{status === 'recording' && (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-full bg-red-500 animate-ping opacity-30" />
|
||||
<div className="w-24 h-24 rounded-full bg-red-600 flex items-center justify-center shadow-lg shadow-red-900/50">
|
||||
<Mic className="w-10 h-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VU meter — 12 bars */}
|
||||
<div className="flex items-end gap-1 h-8">
|
||||
{Array.from({ length: 12 }, (_, i) => {
|
||||
const threshold = (i + 1) / 12
|
||||
const active = vuLevel >= threshold
|
||||
const color = i < 8 ? 'bg-green-400' : i < 10 ? 'bg-yellow-400' : 'bg-red-500'
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cn(
|
||||
'w-2 rounded-sm transition-all duration-75',
|
||||
active ? color : 'bg-slate-700',
|
||||
)}
|
||||
style={{ height: `${50 + i * 4}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-xl bg-slate-700 hover:bg-slate-600 transition-all active:scale-95"
|
||||
>
|
||||
<Square className="w-5 h-5" />
|
||||
Finalizar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Finalizing */}
|
||||
{status === 'finalizing' && (
|
||||
<div className="flex flex-col items-center gap-3 text-slate-300">
|
||||
<Loader2 className="w-12 h-12 animate-spin text-green-400" />
|
||||
<p className="font-medium">Finalizando gravação…</p>
|
||||
<p className="text-sm text-slate-500">Enviando segmentos pendentes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{status === 'error' && (
|
||||
<div className="text-center max-w-sm">
|
||||
<p className="text-red-400 font-medium mb-2">⚠️ {errorMsg}</p>
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="px-4 py-2 bg-slate-700 rounded-lg text-sm hover:bg-slate-600"
|
||||
>
|
||||
Tentar novamente
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { LogOut, Bell, Info } from 'lucide-react'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
export function Settings() {
|
||||
const navigate = useNavigate()
|
||||
const { user, logout } = useAuthStore()
|
||||
const [notifGranted, setNotifGranted] = useState(Notification.permission === 'granted')
|
||||
|
||||
async function requestNotifications() {
|
||||
const result = await Notification.requestPermission()
|
||||
setNotifGranted(result === 'granted')
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="p-4 max-w-md mx-auto space-y-4">
|
||||
<h1 className="text-xl font-bold text-white">Configurações</h1>
|
||||
|
||||
{/* Profile */}
|
||||
<Card>
|
||||
<CardContent className="space-y-1">
|
||||
<p className="text-slate-400 text-xs uppercase tracking-wider font-medium">Conta</p>
|
||||
<p className="text-white font-medium">{user?.name}</p>
|
||||
<p className="text-slate-400 text-sm">{user?.email}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notifications */}
|
||||
<Card>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell size={18} className="text-slate-400" />
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">Notificações</p>
|
||||
<p className="text-slate-400 text-xs">{notifGranted ? 'Ativadas' : 'Desativadas'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{!notifGranted && (
|
||||
<Button size="sm" variant="outline" onClick={requestNotifications}>Ativar</Button>
|
||||
)}
|
||||
{notifGranted && <span className="text-green-400 text-sm">✓</span>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* PWA Install hint */}
|
||||
<Card>
|
||||
<CardContent className="flex items-start gap-3">
|
||||
<Info size={18} className="text-slate-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">Instalar como app</p>
|
||||
<p className="text-slate-400 text-xs mt-0.5">
|
||||
No Chrome: toque no menu ⋮ → "Adicionar à tela inicial". O app funcionará como nativo, mesmo sem internet.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* About */}
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-3">
|
||||
<img src="/images/logo-asap.png" alt="ASAP Telecom" className="h-8 rounded bg-white px-1.5 py-0.5" />
|
||||
<div>
|
||||
<p className="text-white text-sm font-medium">MAIA — Meeting AI Assistant</p>
|
||||
<p className="text-slate-400 text-xs">v1.0.0 — PWA · ASAP Telecom</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button variant="destructive" onClick={handleLogout} className="w-full">
|
||||
<LogOut size={18} /> Sair da conta
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
import { useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Upload as UploadIcon, FileAudio, X, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { meetingsApi } from '@/api/meetings'
|
||||
import { useMeetingsStore } from '@/store/meetingsStore'
|
||||
|
||||
export function UploadPage() {
|
||||
const navigate = useNavigate()
|
||||
const addMeeting = useMeetingsStore((s) => s.addMeeting)
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [title, setTitle] = useState('')
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function handleFile(f: File) {
|
||||
if (!f.type.startsWith('audio/') && !f.name.match(/\.(mp3|wav|m4a|ogg|webm|flac|aac)$/i)) {
|
||||
setError('Formato não suportado. Use MP3, WAV, M4A, OGG, WEBM ou FLAC.')
|
||||
return
|
||||
}
|
||||
setFile(f)
|
||||
setError('')
|
||||
}
|
||||
|
||||
function onDrop(e: React.DragEvent) {
|
||||
e.preventDefault()
|
||||
setDragging(false)
|
||||
const f = e.dataTransfer.files[0]
|
||||
if (f) handleFile(f)
|
||||
}
|
||||
|
||||
async function handleUpload() {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
try {
|
||||
const { data } = await meetingsApi.upload(file, title || undefined, setProgress)
|
||||
addMeeting(data)
|
||||
navigate(`/meetings/${data.id}`)
|
||||
} catch {
|
||||
setError('Erro ao enviar o arquivo. Verifique sua conexão e tente novamente.')
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="p-4 max-w-md mx-auto space-y-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-white mb-1">Enviar áudio</h1>
|
||||
<p className="text-slate-400 text-sm">Envie uma gravação feita com seu app nativo (WhatsApp, Voice Recorder, etc.)</p>
|
||||
</div>
|
||||
|
||||
{/* Drop zone */}
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDrop={onDrop}
|
||||
onDragOver={(e) => { e.preventDefault(); setDragging(true) }}
|
||||
onDragLeave={() => setDragging(false)}
|
||||
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-colors ${dragging ? 'border-green-500 bg-green-950/20' : 'border-slate-600 hover:border-slate-500'}`}
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex items-center justify-center gap-3">
|
||||
<FileAudio size={24} className="text-green-400" />
|
||||
<div className="text-left">
|
||||
<p className="text-white text-sm font-medium">{file.name}</p>
|
||||
<p className="text-slate-400 text-xs">{(file.size / 1024 / 1024).toFixed(1)} MB</p>
|
||||
</div>
|
||||
<button onClick={(e) => { e.stopPropagation(); setFile(null) }} className="text-slate-400 hover:text-white ml-auto">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<UploadIcon size={32} className="mx-auto text-slate-400 mb-2" />
|
||||
<p className="text-slate-300 text-sm">Toque para selecionar ou arraste o arquivo</p>
|
||||
<p className="text-slate-500 text-xs mt-1">MP3, WAV, M4A, OGG, WEBM, FLAC</p>
|
||||
</>
|
||||
)}
|
||||
<input ref={inputRef} type="file" accept="audio/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f) }} />
|
||||
</div>
|
||||
|
||||
{file && (
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-1">Título (opcional)</label>
|
||||
<Input placeholder="Ex: Reunião de vendas — 14/04" value={title} onChange={(e) => setTitle(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <p className="text-red-400 text-sm bg-red-950 rounded-lg p-3">{error}</p>}
|
||||
|
||||
{uploading && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm text-slate-300">
|
||||
<span className="flex items-center gap-2"><Loader2 size={14} className="animate-spin" /> Enviando...</span>
|
||||
<span>{progress}%</span>
|
||||
</div>
|
||||
<div className="h-2 bg-slate-700 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-green-500 rounded-full transition-all" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleUpload} disabled={!file || uploading} className="w-full" size="lg">
|
||||
{uploading ? 'Enviando...' : 'Enviar para análise'}
|
||||
</Button>
|
||||
</div>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { openDB, type IDBPDatabase } from 'idb'
|
||||
import type { OfflineChunk } from '@/types'
|
||||
|
||||
const DB_NAME = 'meeting-assistant'
|
||||
const DB_VERSION = 2
|
||||
|
||||
let _db: IDBPDatabase | null = null
|
||||
|
||||
export async function getDB() {
|
||||
if (!_db) {
|
||||
_db = await openDB(DB_NAME, DB_VERSION, {
|
||||
upgrade(db, oldVersion) {
|
||||
// v1: old 'chunks' store (may exist)
|
||||
if (oldVersion < 2) {
|
||||
if (!db.objectStoreNames.contains('pendingChunks')) {
|
||||
const store = db.createObjectStore('pendingChunks', { keyPath: 'id', autoIncrement: true })
|
||||
store.createIndex('meetingId', 'meetingId')
|
||||
store.createIndex('status', 'status')
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
return _db
|
||||
}
|
||||
|
||||
export async function saveChunk(chunk: Omit<OfflineChunk, 'id'>): Promise<number> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
return db.add('pendingChunks', chunk) as Promise<number>
|
||||
} catch (e) {
|
||||
console.warn('[db] saveChunk failed', e)
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPendingChunks(meetingId?: string): Promise<(OfflineChunk & { id: number })[]> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
const all = await db.getAllFromIndex('pendingChunks', 'status', 'pending') as (OfflineChunk & { id: number })[]
|
||||
if (meetingId) return all.filter(c => c.meetingId === meetingId)
|
||||
return all
|
||||
} catch (e) {
|
||||
console.warn('[db] getPendingChunks failed', e)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function updateChunkStatus(id: number, status: OfflineChunk['status']): Promise<void> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
const chunk = await db.get('pendingChunks', id)
|
||||
if (chunk) await db.put('pendingChunks', { ...chunk, status })
|
||||
} catch (e) {
|
||||
console.warn('[db] updateChunkStatus failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteChunk(id: number): Promise<void> {
|
||||
try {
|
||||
const db = await getDB()
|
||||
await db.delete('pendingChunks', id)
|
||||
} catch (e) {
|
||||
console.warn('[db] deleteChunk failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
// Namespace export for convenience
|
||||
export const db = { saveChunk, getPendingChunks, updateChunkStatus, deleteChunk }
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import Keycloak from 'keycloak-js'
|
||||
|
||||
const keycloak = new Keycloak({
|
||||
url: 'https://auth.asaptelecom.com.br',
|
||||
realm: 'one',
|
||||
clientId: 'maia-app',
|
||||
})
|
||||
|
||||
export default keycloak
|
||||
|
||||
export const REQUIRED_ROLE = 'maia.app'
|
||||
|
||||
export function hasRole(role: string): boolean {
|
||||
return keycloak.hasRealmRole(role)
|
||||
}
|
||||