Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
BIN
Proces_Verbal_Verificare_LOT1.docx
Normal file
BIN
Proces_Verbal_Verificare_LOT1.docx
Normal file
Binary file not shown.
147
README_LIVRABIL.md
Normal file
147
README_LIVRABIL.md
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
# Livrabil LOT 1 — Sistem AI (DiDi / PNRR DIGI150)
|
||||||
|
|
||||||
|
Pachet de lucru pentru continuarea LOT 1 pe serverul cu **inferență GPU**. Conține
|
||||||
|
codul `ai_platform` (curățat), documentația de achiziție (caiet + ofertă) și
|
||||||
|
raportul de audit cod-vs-caiet-vs-ofertă.
|
||||||
|
|
||||||
|
> **Scop:** muți acest folder pe serverul cu inferență, configurezi modelele
|
||||||
|
> reale și finalizezi punctele rămase (vezi §5). Tot ce s-a putut face fără GPU
|
||||||
|
> e deja făcut și validat în Docker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Structura pachetului
|
||||||
|
|
||||||
|
```
|
||||||
|
livrabil_lot1/
|
||||||
|
├── README_LIVRABIL.md ← acest fișier (handoff)
|
||||||
|
├── ai_platform/ ← codul LOT 1 (curățat: fără node_modules/.git/runs/cache)
|
||||||
|
│ ├── modules/<modul>/ ← fiecare modul: src/ tests/ deploy/ README.md API.md
|
||||||
|
│ ├── CLAUDE.md INDEX.md ← convenții + overview oficial
|
||||||
|
│ └── DEPLOYMENT.md bootstrap.sh
|
||||||
|
└── documentatie/
|
||||||
|
├── Caiet_de_sarcini.pdf
|
||||||
|
├── Propunere_tehnica.pdf / Lot1.pdf / LOT1_AI_Propunere_Tehnica.docx
|
||||||
|
└── AUDIT_LOT1.md ← audit complet pe module (citește-l pentru detalii)
|
||||||
|
```
|
||||||
|
|
||||||
|
`uv.lock` au fost excluse intenționat (se regenerează cu `uv sync`). `node_modules`
|
||||||
|
se regenerează cu `npm ci`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ce este LOT 1 — harta modulelor (logica)
|
||||||
|
|
||||||
|
Sistemul AI = servicii independente, containerizate, pe care backend-ul (LOT 2)
|
||||||
|
le consumă prin API. Fiecare modul are `deploy/deploy.sh`, `docker-compose.yml`,
|
||||||
|
`Dockerfile`, `.env.example`.
|
||||||
|
|
||||||
|
| Modul | Ce face | Stare |
|
||||||
|
|---|---|---|
|
||||||
|
| **llm-inference** | Gateway OpenAI-compatible peste 3 backend-uri (vLLM/llama.cpp/LiteLLM); rutare + fallback + metrici | 🟢 ~95% |
|
||||||
|
| **extractors** *(nou)* | Feature extraction *ușor*: metadata (EXIF/ELA/spectrogramă/integritate), sentiment/OCR (deleagă LLM), NER (GLiNER), detect (YOLO) | 🟢 ~95% |
|
||||||
|
| **video-analysis** | Deepfake video (BusterX/vLLM), verdict REAL/FAKE/UNCERTAIN + frames + evidence | 🟢 |
|
||||||
|
| **audio** | Transcriere (faster-whisper) | 🟢 |
|
||||||
|
| **web** | Web crawl & evidence (Playwright, multi-provider search) | 🟡 90% |
|
||||||
|
| **embeddings / rerank** | bge-m3 / bge-reranker-v2-m3, OpenAI-compatible | 🟢 85% |
|
||||||
|
| **didi_brain** | Cache rezultate (Postgres/pgvector) + RAG (Atomic) + fact-checking; consumat de backend | 🟢 |
|
||||||
|
| **dashboard** | Admin UI (React+FastAPI): monitorizare AI, catalog modele DB, config, RBAC | 🟡 ~78% |
|
||||||
|
| **catalog-api** | Agregator service-discovery (`/v1/info`), stateless | 🟢 |
|
||||||
|
| **gateway** | Reverse-proxy nginx (config-only) — punct de intrare AI | 🟢 |
|
||||||
|
| **forensic_features** | Detectoare forensice (rPPG/lip-sync/forgery heatmap/lighting) | 🟢 |
|
||||||
|
| **cloak** | Scraper SERP stealth (tier-3 fallback pt `web`) | 🟢 |
|
||||||
|
|
||||||
|
Detalii complete + procente per cerință: **`documentatie/AUDIT_LOT1.md`**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Ce s-a remediat în această iterație (rezumat)
|
||||||
|
|
||||||
|
Toate validate rulând în container (mediul de build n-are python/GPU local).
|
||||||
|
|
||||||
|
- **Extractor ML — modul nou `extractors`** (39 teste): `/v1/metadata` (EXIF, codec/bitrate via ffprobe, spectrogramă numpy, ELA, integritate/C2PA), `/v1/sentiment` + `/v1/ocr` (deleagă la gateway-ul LLM), `/v1/ner` (GLiNER, validat pe RO), `/v1/detect` (YOLOv8, validat real).
|
||||||
|
- **video-analysis** — verdict aliniat: enum `UNCERTAIN`, `frames_analyzed` + `evidence[]`; reparat bug pyproject (virgulă lipsă → modul neinstalabil). 7 teste.
|
||||||
|
- **dashboard** — *AI Monitoring* (health agregat + cozi RabbitMQ + latențe Prometheus, fail-open), *Catalog modele/extractoare DB-backed* + CRUD, *RBAC* pe suprafața de citire + warning staging. 12 teste pe Postgres. Reparat 2 bug-uri reale (audit NOT NULL, Query default).
|
||||||
|
- **llm-inference** — model `qwen3.5` + alias, `/v1/completions`, **fallback cascadă** cross-backend, **metrici Prometheus** (latență/tokens/requests). 100 teste. Pin `fastapi<0.116`.
|
||||||
|
- **Cache** — reclasificat: acoperit la nivel de orchestrare (didi_brain + Redis backend), nu se construiește cache nou în LOT 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Cum se validează (fără a instala nimic local) — reț. Docker
|
||||||
|
|
||||||
|
Mediul de dezvoltare n-are python/uv/ffmpeg/GPU. Tot se rulează în container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Teste unui modul Python (în containerul lui de build):
|
||||||
|
cd ai_platform/modules/<modul>
|
||||||
|
docker run --rm -v "$PWD":/app -w /app python:3.11-slim \
|
||||||
|
bash -c "pip install -e '.[dev]' && pytest -q"
|
||||||
|
|
||||||
|
# Module cu model greu (extractors NER/YOLO): instalează extra-ul ml
|
||||||
|
# pip install -e '.[ml]' (trage torch — imagine mai mare)
|
||||||
|
|
||||||
|
# dashboard (are nevoie de Postgres): Postgres efemer în Docker
|
||||||
|
docker run -d --name pg --network <net> -e POSTGRES_PASSWORD=test -e POSTGRES_DB=test postgres:16-alpine
|
||||||
|
docker run --rm --network <net> -v "$PWD":/app -w /app \
|
||||||
|
-e TEST_DATABASE_URL="postgresql+asyncpg://postgres:test@pg:5432/test" \
|
||||||
|
python:3.11-slim bash -c "pip install -e '.[dev]' && pytest -q"
|
||||||
|
|
||||||
|
# frontend dashboard (Vite 7 cere Node 20+):
|
||||||
|
cd ai_platform/modules/dashboard/web
|
||||||
|
docker run --rm -v "$PWD":/app -w /app node:20-slim bash -c "npm ci && npx tsc -b --noEmit"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Ce mai e de făcut pe serverul cu inferență
|
||||||
|
|
||||||
|
### 5.1 Configurări obligatorii la mutare (altfel serviciile nu „văd" modelele)
|
||||||
|
- **llm-inference**: pornește vLLM cu `--served-model-name qwen3.5` (sau setează
|
||||||
|
`LLM_MODEL_ALIASES`), `VLLM_MODEL=Qwen/Qwen3.5-35B-A3B`. Vezi `.env.example`.
|
||||||
|
- **extractors**: `EXTRACTORS_LLM_GATEWAY_URL=http://<llm-gateway>:14011` (pt sentiment/OCR).
|
||||||
|
- **video-analysis / audio / embeddings / rerank**: setează URL-urile vLLM/model în `.env`.
|
||||||
|
- **dashboard**: pentru a aprinde panourile noi —
|
||||||
|
`DASHBOARD_PROMETHEUS_URL=http://<prometheus>:9090`,
|
||||||
|
`DASHBOARD_RABBITMQ_MGMT_URL=http://<rabbitmq>:15672` (+ user/parolă).
|
||||||
|
RBAC se activează automat când e setat `DASHBOARD_KEYCLOAK_URL`; **scoate
|
||||||
|
`DASHBOARD_STAGING_MODE` în producție** (bypassează auth).
|
||||||
|
|
||||||
|
### 5.2 Cod rămas (validabil pe server cu modelele reale)
|
||||||
|
- **llm-inference — load/unload model** (Task 4): vLLM **nu** suportă hot-swap
|
||||||
|
nativ (un model/proces). Decizie: (a) implementezi „register/route" — load =
|
||||||
|
asiguri că un backend servește modelul X; sau (b) marchezi „nesuportat de vLLM"
|
||||||
|
în ofertă/recepție. *(singurul punct deschis la inferență)*
|
||||||
|
- **web** (ofertă): `robots.txt`, rate-limit per-domeniu, jurnal audit persistent.
|
||||||
|
- **embeddings/rerank**: failover automat între backend-uri + teste API.
|
||||||
|
- **RAG**: wrapper de contract `/v1/query`/`/v1/index` peste `/v1/gather` din
|
||||||
|
didi_brain (sau acceptat în forma livrată).
|
||||||
|
- **CI/CD**: non-root pe 5 Dockerfile, rollback + publish imagini AI în registry.
|
||||||
|
|
||||||
|
### 5.3 Infra (manifeste de scris; validare reală doar cu cluster/GPU)
|
||||||
|
- **Orchestrare / scalare automată** (caiet 747-766) — k8s/Helm + HPA. *Singurul
|
||||||
|
gap mare de caiet rămas.*
|
||||||
|
- **dashboard GPU/VRAM** — exporter dcgm pe host-ul GPU + panou (codul de panou se
|
||||||
|
cablează ca latențele, prin Prometheus).
|
||||||
|
- **dashboard control rute** — gateway-ul AI e nginx static; control real = view +
|
||||||
|
enable/disable prin regenerare config + reload (NU Kong, care e LOT 2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Pornire rapidă (per modul)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ai_platform/modules/<modul>/deploy
|
||||||
|
cp ../.env.example .env # completează variabilele (fail-fast, fără defaults)
|
||||||
|
./deploy.sh up # sau: docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Convenție porturi: `1xxxx` producție / `5xxxx` dev (vezi `ai_platform/CLAUDE.md`).
|
||||||
|
Bootstrap pentru host CPU-only: `ai_platform/bootstrap.sh` + `DEPLOYMENT.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Pointere
|
||||||
|
- **Audit complet pe cerințe** (procente, file:line, ce lipsește): `documentatie/AUDIT_LOT1.md`
|
||||||
|
- Convenții cod + porturi: `ai_platform/CLAUDE.md`
|
||||||
|
- Overview module: `ai_platform/INDEX.md`
|
||||||
|
- Fiecare modul: `README.md` + `API.md` + `INDEX.md` propriu.
|
||||||
91
ai_platform/.env.example
Normal file
91
ai_platform/.env.example
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# didiAI Platform - Environment Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to .env and configure the required values
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# cp .env.example .env
|
||||||
|
# nano .env # Edit and fill in your values
|
||||||
|
# ./didiAI-deploy.sh up
|
||||||
|
#
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REQUIRED - Hugging Face (for model downloads)
|
||||||
|
# =============================================================================
|
||||||
|
# Get your token from: https://huggingface.co/settings/tokens
|
||||||
|
HF_TOKEN=your_huggingface_token_here
|
||||||
|
|
||||||
|
# Model cache directory (shared across all modules)
|
||||||
|
# Default: /cai2_ds_storage/hf_cache
|
||||||
|
HF_CACHE_DIR=/cai2_ds_storage/hf_cache
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# REQUIRED - SearXNG (for Web module search)
|
||||||
|
# =============================================================================
|
||||||
|
# The Web module uses SearXNG (self-hosted metasearch engine) as its primary
|
||||||
|
# search provider. SearXNG must be running and accessible.
|
||||||
|
# Deploy SearXNG via its own docker-compose or use an existing instance.
|
||||||
|
WEB_SEARXNG_BASE_URL=http://didiAI-web-searxng:8080
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OPTIONAL - External LLM APIs (fallback/litellm backend)
|
||||||
|
# =============================================================================
|
||||||
|
# Only needed if using litellm backend or external fallbacks
|
||||||
|
|
||||||
|
# OpenRouter API (multiple models via one API)
|
||||||
|
# Get key from: https://openrouter.ai/keys
|
||||||
|
OPENROUTER_API_KEY=
|
||||||
|
|
||||||
|
# OpenAI API (for GPT models)
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
|
||||||
|
# Anthropic API (for Claude models)
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# OPTIONAL - Module Configuration
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# LLM Module (defaults are set by deploy script)
|
||||||
|
LLM_DEFAULT_BACKEND=vllm
|
||||||
|
LLM_ENABLE_VLLM=true
|
||||||
|
LLM_ENABLE_LLAMACPP=false
|
||||||
|
|
||||||
|
# Audio Module (defaults are set by deploy script)
|
||||||
|
AUDIO_MODEL=large-v3-turbo
|
||||||
|
AUDIO_DEVICE=cuda
|
||||||
|
AUDIO_COMPUTE_TYPE=int8
|
||||||
|
AUDIO_CACHE_DIR=/cai2_ds_storage/hf_cache
|
||||||
|
|
||||||
|
# Video Module (defaults are set by deploy script)
|
||||||
|
VIDEO_ANALYSIS_VLLM_BASE_URL=http://didiAI-video-vllm-buster:8000
|
||||||
|
VIDEO_ANALYSIS_VLLM_MODEL=busterx
|
||||||
|
VIDEO_ANALYSIS_RUNS_DIR=/home/vasi/ml-projects/modules/video-analysis/runs
|
||||||
|
|
||||||
|
# Web Module (defaults are set automatically)
|
||||||
|
WEB_LLM_BASE_URL=http://didiAI-llm-api:14011
|
||||||
|
WEB_VISION_MODEL=qwen-vl
|
||||||
|
WEB_TEXT_MODEL=qwen3-235b
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# GPU Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# GPU 0: Qwen3.5-35B-A3B (text + vision, ~57GB VRAM) + Whisper (~2GB)
|
||||||
|
#
|
||||||
|
# CUDA_VISIBLE_DEVICES is set per module in docker-compose.yml
|
||||||
|
# No need to configure here unless changing GPU allocation
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Nginx Timeouts (optional tuning)
|
||||||
|
# =============================================================================
|
||||||
|
NGINX_CONNECT_TIMEOUT=60s
|
||||||
|
NGINX_SEND_TIMEOUT=120s
|
||||||
|
NGINX_READ_TIMEOUT=600s
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Logging (optional)
|
||||||
|
# =============================================================================
|
||||||
|
# Log levels: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||||
|
CATALOG_LOG_LEVEL=INFO
|
||||||
|
AUDIO_LOG_LEVEL=INFO
|
||||||
|
WEB_LOG_LEVEL=INFO
|
||||||
107
ai_platform/.gitignore
vendored
Normal file
107
ai_platform/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# ruff
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Project-specific
|
||||||
|
*.log
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# runtime outputs
|
||||||
|
modules/video-analysis/runs/
|
||||||
34
ai_platform/.pre-commit-config.yaml
Normal file
34
ai_platform/.pre-commit-config.yaml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Pre-commit hooks configuration
|
||||||
|
# Install: pre-commit install
|
||||||
|
# Run manually: pre-commit run --all-files
|
||||||
|
|
||||||
|
repos:
|
||||||
|
# Ruff - Python linting and formatting
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.8.6
|
||||||
|
hooks:
|
||||||
|
- id: ruff
|
||||||
|
args: [--fix, --exit-non-zero-on-fix]
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
# General file checks
|
||||||
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
|
rev: v5.0.0
|
||||||
|
hooks:
|
||||||
|
- id: check-yaml
|
||||||
|
args: [--unsafe] # Allow custom YAML tags (e.g., for docker-compose)
|
||||||
|
- id: check-toml
|
||||||
|
- id: check-json
|
||||||
|
- id: end-of-file-fixer
|
||||||
|
- id: trailing-whitespace
|
||||||
|
- id: check-added-large-files
|
||||||
|
args: [--maxkb=1000]
|
||||||
|
- id: check-merge-conflict
|
||||||
|
- id: detect-private-key
|
||||||
|
|
||||||
|
# Type checking (optional - uncomment when ready)
|
||||||
|
# - repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
# rev: v1.13.0
|
||||||
|
# hooks:
|
||||||
|
# - id: mypy
|
||||||
|
# additional_dependencies: []
|
||||||
472
ai_platform/DEPLOYMENT.md
Normal file
472
ai_platform/DEPLOYMENT.md
Normal file
|
|
@ -0,0 +1,472 @@
|
||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
This guide covers bootstrapping the **web + dashboard** stack on a fresh CPU-only host. All GPU-dependent services (`llm-inference`, `embeddings`, `rerank`, `audio`, `video-analysis`) remain on a separate GPU machine and are called over HTTP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────── CPU-only host (this machine) ──────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┐ ┌───────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Backend clients │────▶ │ Web API (:51100) │ │
|
||||||
|
│ │ (your app / UI) │ │ ├─ free tier → SearXNG │ │
|
||||||
|
│ └────────────────────┘ │ └─ premium tier → SerpAPI/Tavily/Brave/LinkUp │ │
|
||||||
|
│ │ + OpenRouter for LLM calls │ │
|
||||||
|
│ └──────────────┬────────────────────────────────────┘ │
|
||||||
|
│ │ fires events │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Dashboard (:51300) ◀── PostgreSQL (:15432 internal) │ │
|
||||||
|
│ │ · overview / providers / history │ │
|
||||||
|
│ │ · /config (runtime toggles) │ │
|
||||||
|
│ │ · /archive (promote gathers for future claims-api) │ │
|
||||||
|
│ │ · /cost (spend + projections) │ │
|
||||||
|
│ └─────────────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌───────────────────┐ │
|
||||||
|
│ │ SearXNG │ (existing — free tier source of truth) │
|
||||||
|
│ │ :55100/8080 │ │
|
||||||
|
│ └───────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└──────────┬────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
│ HTTP calls (over VPN / LAN)
|
||||||
|
▼
|
||||||
|
┌─────────────────────────── GPU host (separate machine) ──────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ llm-inference (:14011) ◀── Web API calls this for context + evidence LLM │
|
||||||
|
│ └─ vLLM Qwen3.5-35B (:14001) │
|
||||||
|
│ └─ llama.cpp servers (optional, on separate GPU nodes) │
|
||||||
|
│ │
|
||||||
|
│ embeddings (:14100) ◀── reserved for future semantic search │
|
||||||
|
│ rerank (:14200) ◀── reserved │
|
||||||
|
│ audio (:54300) ◀── whisper transcription │
|
||||||
|
│ video-api (:54600) ◀── deepfake detection │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### What's deployed where
|
||||||
|
|
||||||
|
| Service | This host (CPU) | GPU host |
|
||||||
|
|-----------------|:---------------:|:--------------:|
|
||||||
|
| SearXNG | ✓ | |
|
||||||
|
| web-api | ✓ | |
|
||||||
|
| dashboard | ✓ | |
|
||||||
|
| dashboard-db | ✓ | |
|
||||||
|
| llm-inference | | ✓ |
|
||||||
|
| embeddings | | ✓ |
|
||||||
|
| rerank | | ✓ |
|
||||||
|
| audio | | ✓ |
|
||||||
|
| video-analysis | | ✓ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **OS:** Linux (Ubuntu 22.04+ or similar)
|
||||||
|
- **Docker:** 24.0+ with Docker Compose V2 (`docker compose` subcommand)
|
||||||
|
- **Disk:** ~2 GB free for images + DB volume
|
||||||
|
- **RAM:** 2 GB+ available for containers
|
||||||
|
- **Network:**
|
||||||
|
- Port 51100 (web-api) + 51300 (dashboard) free on the host
|
||||||
|
- 15432 free if you want the dashboard DB exposed for debugging
|
||||||
|
- Outbound HTTPS to SerpAPI, Tavily, Brave, LinkUp, OpenRouter (if using premium)
|
||||||
|
- LAN/VPN reachability to your GPU host for `WEB_LLM_BASE_URL`
|
||||||
|
|
||||||
|
Not required on this host: NVIDIA driver, CUDA, nvidia-container-toolkit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quickstart (one command)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.finesynergy.eu/ml/ml-projects.git
|
||||||
|
cd ml-projects
|
||||||
|
./bootstrap.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The script will:
|
||||||
|
|
||||||
|
1. Verify Docker + Compose.
|
||||||
|
2. Create the `deploy_default` network.
|
||||||
|
3. Prompt for:
|
||||||
|
- GPU host address (for LLM URL)
|
||||||
|
- SearXNG container name / URL
|
||||||
|
- Paid provider keys (SerpAPI, Tavily, Brave, LinkUp, OpenRouter — leave blank to skip)
|
||||||
|
- Generate a random Postgres password
|
||||||
|
4. Render `modules/web/deploy/.env` and `modules/dashboard/deploy/.env`.
|
||||||
|
5. Deploy dashboard + web-api.
|
||||||
|
6. Create an admin user and print the Bearer token.
|
||||||
|
7. Run smoke tests.
|
||||||
|
|
||||||
|
### Script flags
|
||||||
|
|
||||||
|
| Flag | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| _(none)_ | Interactive — prompts for everything |
|
||||||
|
| `--non-interactive` | Use existing `.env` files without prompting |
|
||||||
|
| `--deploy-searxng` | Also deploy the bundled SearXNG stack from `modules/web/deploy/metasearch` |
|
||||||
|
| `--skip-smoke` | Skip post-deploy smoke tests |
|
||||||
|
| `--help` | Show usage |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual deployment (step by step)
|
||||||
|
|
||||||
|
If the bootstrap script fails or you prefer manual control:
|
||||||
|
|
||||||
|
### 1. Create the network
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network inspect deploy_default >/dev/null 2>&1 \
|
||||||
|
|| docker network create deploy_default
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Ensure SearXNG is reachable
|
||||||
|
|
||||||
|
If SearXNG is already running on this host, connect it to the network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network connect deploy_default <searxng-container-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
Otherwise, deploy the bundled SearXNG:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd modules/web/deploy/metasearch
|
||||||
|
docker compose up -d
|
||||||
|
cd -
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure and deploy the dashboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp modules/dashboard/.env.example modules/dashboard/deploy/.env
|
||||||
|
# Edit modules/dashboard/deploy/.env — set:
|
||||||
|
# DASHBOARD_DB_PASSWORD=<strong-random-password>
|
||||||
|
# DASHBOARD_LLM_API_URL=http://<your-gpu-host>:14011
|
||||||
|
# DASHBOARD_VLLM_QWEN_URL=http://<your-gpu-host>:14001
|
||||||
|
# (paid provider keys if you want live quota readouts)
|
||||||
|
|
||||||
|
cd modules/dashboard/deploy
|
||||||
|
./deploy.sh up
|
||||||
|
|
||||||
|
# Wait for health
|
||||||
|
curl http://localhost:51300/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Configure and deploy web-api
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp modules/web/.env.example modules/web/deploy/.env
|
||||||
|
# Edit modules/web/deploy/.env — set:
|
||||||
|
# WEB_SEARXNG_BASE_URL=http://<searxng-container>:8080
|
||||||
|
# WEB_LLM_BASE_URL=http://<your-gpu-host>:14011
|
||||||
|
# WEB_VISION_BASE_URL=http://<your-gpu-host>:14011
|
||||||
|
# WEB_DASHBOARD_URL=http://didiAI-dashboard:51300
|
||||||
|
# (paid provider keys for premium tier)
|
||||||
|
# (WEB_OPENROUTER_API_KEY for premium LLM)
|
||||||
|
|
||||||
|
cd modules/web/deploy
|
||||||
|
docker compose --profile api up -d --build
|
||||||
|
|
||||||
|
# Wait for health
|
||||||
|
curl http://localhost:51100/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create an admin user on the dashboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it didiAI-dashboard python -m dashboard.cli \
|
||||||
|
create-user <your-username> \
|
||||||
|
--email <your-email> \
|
||||||
|
--role admin
|
||||||
|
```
|
||||||
|
|
||||||
|
Save the Bearer token it prints — it's not stored in plain text and can't be recovered.
|
||||||
|
|
||||||
|
### 6. Verify everything works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dashboard
|
||||||
|
curl http://localhost:51300/health
|
||||||
|
curl http://localhost:51300/api/stats/providers | jq '.providers | length'
|
||||||
|
|
||||||
|
# Free tier search (uses SearXNG)
|
||||||
|
curl -X POST http://localhost:51100/v1/search \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"queries":["hello world"],"max_results":3}'
|
||||||
|
|
||||||
|
# Premium tier search (uses paid rotation)
|
||||||
|
curl -X POST http://localhost:51100/v1/search \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Search-Tier: premium" \
|
||||||
|
-d '{"queries":["hello world"],"max_results":3}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Then browse to <http://localhost:51300> for the UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration reference
|
||||||
|
|
||||||
|
### Required values for this host
|
||||||
|
|
||||||
|
| File | Variable | Purpose |
|
||||||
|
|------|----------|---------|
|
||||||
|
| `modules/web/deploy/.env` | `WEB_SEARXNG_BASE_URL` | SearXNG internal URL |
|
||||||
|
| `modules/web/deploy/.env` | `WEB_LLM_BASE_URL` | Remote LLM inference endpoint |
|
||||||
|
| `modules/web/deploy/.env` | `WEB_VISION_BASE_URL` | Usually same as LLM URL |
|
||||||
|
| `modules/web/deploy/.env` | `WEB_EXTERNAL_URL` | Public-facing web-api URL |
|
||||||
|
| `modules/dashboard/deploy/.env` | `DASHBOARD_DB_PASSWORD` | Random strong password |
|
||||||
|
| `modules/dashboard/deploy/.env` | `DASHBOARD_EXTERNAL_URL` | Public-facing dashboard URL |
|
||||||
|
| `modules/dashboard/deploy/.env` | `DASHBOARD_LLM_API_URL` | Remote LLM (for health check card) |
|
||||||
|
|
||||||
|
### Optional — enable premium tier
|
||||||
|
|
||||||
|
Set **at least one** of these in `modules/web/deploy/.env`. If all are blank, premium tier returns empty results.
|
||||||
|
|
||||||
|
```
|
||||||
|
WEB_SERPAPI_API_KEY=...
|
||||||
|
WEB_TAVILY_API_KEY=...
|
||||||
|
WEB_BRAVE_API_KEY=...
|
||||||
|
WEB_LINKUP_API_KEY=...
|
||||||
|
WEB_OPENROUTER_API_KEY=...
|
||||||
|
WEB_OPENROUTER_MODEL=google/gemini-3.1-flash-lite-preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Also mirror these into `modules/dashboard/deploy/.env` (`DASHBOARD_*` prefix) so the dashboard can show live quota.
|
||||||
|
|
||||||
|
### Dashboard ↔ Web wiring
|
||||||
|
|
||||||
|
The web-api polls the dashboard for runtime config every 30s and fires events back. This requires:
|
||||||
|
|
||||||
|
```
|
||||||
|
# modules/web/deploy/.env
|
||||||
|
WEB_DASHBOARD_URL=http://didiAI-dashboard:51300
|
||||||
|
```
|
||||||
|
|
||||||
|
Both containers must be on the `deploy_default` network for internal hostname resolution.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sending traffic
|
||||||
|
|
||||||
|
Send `X-Search-Tier` to select the pipeline:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Free — SearXNG only + remote Qwen LLM (cheap / slow / open)
|
||||||
|
curl -X POST http://localhost:51100/v1/gather \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"claim":"...","max_search_results":10}'
|
||||||
|
|
||||||
|
# Premium — paid rotation + OpenRouter (fast / higher quality)
|
||||||
|
curl -X POST http://localhost:51100/v1/gather \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "X-Search-Tier: premium" \
|
||||||
|
-d '{"claim":"...","max_search_results":10}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Response schema is identical between tiers. Only the upstream sources differ.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Operations
|
||||||
|
|
||||||
|
### Tailing logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs -f didiAI-web-api
|
||||||
|
docker logs -f didiAI-dashboard
|
||||||
|
docker logs -f didiAI-dashboard-db
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restarting a single container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker restart didiAI-web-api
|
||||||
|
# or for a full rebuild:
|
||||||
|
cd modules/web/deploy && docker compose --profile api up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
### Managing users
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create
|
||||||
|
docker exec didiAI-dashboard python -m dashboard.cli create-user alice \
|
||||||
|
--email alice@example.com --role admin
|
||||||
|
|
||||||
|
# List
|
||||||
|
docker exec didiAI-dashboard python -m dashboard.cli list-users
|
||||||
|
|
||||||
|
# Delete
|
||||||
|
docker exec didiAI-dashboard python -m dashboard.cli delete-user alice
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime configuration (no restart needed)
|
||||||
|
|
||||||
|
Open `http://localhost:51300/config` for the UI, or use the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
TOKEN="<your admin token>"
|
||||||
|
|
||||||
|
# Disable a provider
|
||||||
|
curl -X PUT http://localhost:51300/api/config/web.providers.serpapi.enabled \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"value": false}'
|
||||||
|
|
||||||
|
# Switch premium rotation strategy
|
||||||
|
curl -X PUT http://localhost:51300/api/config/web.premium.strategy \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"value": "parallel"}'
|
||||||
|
|
||||||
|
# Swap OpenRouter model
|
||||||
|
curl -X PUT http://localhost:51300/api/config/web.openrouter.model \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"value": "anthropic/claude-haiku-4.5"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Changes propagate within 30 seconds (next poll cycle) — or faster with `docker restart didiAI-web-api`.
|
||||||
|
|
||||||
|
Audit trail at `http://localhost:51300/audit`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backup & restore
|
||||||
|
|
||||||
|
### Dashboard database
|
||||||
|
|
||||||
|
The dashboard stores request history, runtime config, audit log, users, and archived claims in PostgreSQL volume `deploy_dashboard_db_data`.
|
||||||
|
|
||||||
|
Nightly backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec didiAI-dashboard-db pg_dump -U dashboard -d dashboard \
|
||||||
|
| gzip > "backup-$(date +%Y%m%d).sql.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restore on a fresh install:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Make sure the dashboard DB container is running but the app is down
|
||||||
|
docker stop didiAI-dashboard
|
||||||
|
gunzip < backup-YYYYMMDD.sql.gz | \
|
||||||
|
docker exec -i didiAI-dashboard-db psql -U dashboard -d dashboard
|
||||||
|
docker start didiAI-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### Config files
|
||||||
|
|
||||||
|
`.env` files under `modules/*/deploy/.env` are gitignored — they are the only stateful config on the host outside PostgreSQL. Back them up securely (they contain provider API keys and the DB password).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `docker network deploy_default not found`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network create deploy_default
|
||||||
|
```
|
||||||
|
|
||||||
|
### SearXNG unreachable from web-api
|
||||||
|
|
||||||
|
Check that SearXNG is on the shared network:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network inspect deploy_default | grep -A2 searxng
|
||||||
|
# If missing:
|
||||||
|
docker network connect deploy_default <searxng-container>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then verify the hostname/port in `WEB_SEARXNG_BASE_URL`.
|
||||||
|
|
||||||
|
### Dashboard shows "LLM unreachable"
|
||||||
|
|
||||||
|
This host has no GPU. `WEB_LLM_BASE_URL` + `DASHBOARD_LLM_API_URL` must point at your GPU machine:
|
||||||
|
|
||||||
|
```
|
||||||
|
WEB_LLM_BASE_URL=http://10.x.x.x:14011
|
||||||
|
```
|
||||||
|
|
||||||
|
Verify from inside the web-api container:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec didiAI-web-api curl -sf http://10.x.x.x:14011/v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
### Premium tier returns empty results
|
||||||
|
|
||||||
|
Open `http://localhost:51300/providers` and check each paid provider's status:
|
||||||
|
|
||||||
|
- **Down / 401** → invalid API key in `.env`. Fix and redeploy web-api.
|
||||||
|
- **429 Too Many Requests** → monthly quota exhausted. Either disable the provider runtime on `/config` or top up the plan.
|
||||||
|
|
||||||
|
### `pg_isready` failing on dashboard-db
|
||||||
|
|
||||||
|
First start takes ~15 s to initialize the database cluster. If it's still failing after 60 s:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs didiAI-dashboard-db --tail 50
|
||||||
|
```
|
||||||
|
|
||||||
|
Common cause: stale volume with different credentials. To wipe and restart:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd modules/dashboard/deploy
|
||||||
|
docker compose --profile dashboard down
|
||||||
|
docker volume rm deploy_dashboard_db_data
|
||||||
|
docker compose --profile dashboard up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Warning:** this deletes all request history, config overrides, archive, and users. Back up first if needed.
|
||||||
|
|
||||||
|
### Port already in use
|
||||||
|
|
||||||
|
Edit the exposed port mapping in `modules/<module>/deploy/docker-compose.yml` and update `*_EXTERNAL_URL` in the matching `.env`.
|
||||||
|
|
||||||
|
### "Invalid token" on `/api/config` PUT
|
||||||
|
|
||||||
|
Use a user token created via `docker exec ... create-user`, not an env-based token. Legacy static tokens in `DASHBOARD_API_TOKENS` still work but only if no users exist in the DB.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Pull latest code and redeploy affected modules:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git pull
|
||||||
|
# Dashboard changes:
|
||||||
|
cd modules/dashboard/deploy && docker compose --profile dashboard up -d --build
|
||||||
|
# Web changes:
|
||||||
|
cd modules/web/deploy && docker compose --profile api up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema migrations happen automatically on startup (the dashboard creates missing tables idempotently). For destructive schema changes, see release notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- All services assume they run on a trusted internal network (VPN / LAN). The dashboard's read endpoints are unauthenticated by design.
|
||||||
|
- Only mutations (`PUT /api/config/*`, `POST /api/archive/promote/*`) require a Bearer token.
|
||||||
|
- If you need to expose any of these on the public internet, put them behind an authenticated reverse proxy and set `WEB_API_TOKENS` / `DASHBOARD_API_TOKENS` to enforce auth on read endpoints too.
|
||||||
|
- Provider API keys live in `.env` files (mode 0644 on disk). Rotate them periodically via each provider's dashboard.
|
||||||
|
- The admin Bearer token is printed once. Store it in a password manager; rotate with `delete-user` + `create-user`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- `CLAUDE.md` — stable coding conventions
|
||||||
|
- `ENDPOINTS.md` — endpoint catalog across modules
|
||||||
|
- `INDEX.md` — module directory
|
||||||
|
- `STATUS.md` — living notes on the active work stream
|
||||||
|
- `modules/web/README.md` — web module details
|
||||||
|
- `modules/dashboard/README.md` — dashboard module details
|
||||||
455
ai_platform/ENDPOINTS.md
Normal file
455
ai_platform/ENDPOINTS.md
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
# 🚀 ML PROJECTS - Complete API Endpoints Reference
|
||||||
|
|
||||||
|
> Last Updated: 2026-02-06
|
||||||
|
> Status: All services operational ✅
|
||||||
|
|
||||||
|
## 📊 Port Allocation Schema
|
||||||
|
|
||||||
|
Porturi alocate conform convenției datacenter:
|
||||||
|
- **1xxxx** = Production
|
||||||
|
- **5xxxx** = Development
|
||||||
|
- **x4xxx** = AI/LLM Services
|
||||||
|
- **x1xxx** = API Gateway
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Quick Status Overview
|
||||||
|
|
||||||
|
### Production (1xxxx)
|
||||||
|
|
||||||
|
| Service | Port | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| **Catalog API** | 11000 | Main orchestrator gateway |
|
||||||
|
| **vLLM Qwen3.5-35B-A3B** | 14001 | Text + Vision LLM (MoE, GPU 0) |
|
||||||
|
| **LLM Inference API** | 14011 | Unified LLM router |
|
||||||
|
| **Embeddings API** | 14100 | OpenAI-compatible embeddings |
|
||||||
|
| - vLLM Embed Server | 14101 | GPU embedding backend |
|
||||||
|
| - llama.cpp Embed Server | 14110 | CPU/GGUF embedding backend |
|
||||||
|
| **Rerank API** | 14200 | Document reranking |
|
||||||
|
| - vLLM Rerank Server | 14201 | GPU reranking backend |
|
||||||
|
| - llama.cpp Rerank Server | 14210 | CPU/GGUF reranking backend |
|
||||||
|
|
||||||
|
### Development (5xxxx)
|
||||||
|
|
||||||
|
| Service | Port | Description |
|
||||||
|
|---------|------|-------------|
|
||||||
|
| **Web API** | 51100 | Fact-checking / web search |
|
||||||
|
| **Embeddings API** | 54100 | OpenAI-compatible embeddings |
|
||||||
|
| - vLLM Embed Server | 54101 | GPU embedding backend |
|
||||||
|
| - llama.cpp Embed Server | 54110 | CPU/GGUF embedding backend |
|
||||||
|
| **Rerank API** | 54200 | Document reranking |
|
||||||
|
| - vLLM Rerank Server | 54201 | GPU reranking backend |
|
||||||
|
| - llama.cpp Rerank Server | 54210 | CPU/GGUF reranking backend |
|
||||||
|
| **Audio API** | 54300 | Speech-to-text (Whisper) |
|
||||||
|
| **BusterX vLLM** | 54500 | Deepfake detection model |
|
||||||
|
| **Video API** | 54600 | Video analysis |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏭 PRODUCTION SERVICES (1xxxx)
|
||||||
|
|
||||||
|
### 1️⃣ Catalog API (Main Gateway)
|
||||||
|
|
||||||
|
**Port:** `11000`
|
||||||
|
**Base URL:** `http://localhost:11000`
|
||||||
|
**Purpose:** Main orchestrator gateway - routes to all services
|
||||||
|
**Decodare:** 1+1+0+0+0 = Prod + API + Gateway
|
||||||
|
|
||||||
|
#### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/health` | Health check |
|
||||||
|
| `GET` | `/ready` | Readiness probe |
|
||||||
|
| `GET` | `/v1/status` | Status of all components |
|
||||||
|
|
||||||
|
#### Quick Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:11000/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2️⃣ vLLM Backend: Qwen3.5-35B-A3B
|
||||||
|
|
||||||
|
**Port:** `14001`
|
||||||
|
**Base URL:** `http://localhost:14001`
|
||||||
|
**Model:** Qwen3.5-35B-A3B (MoE, native text + vision)
|
||||||
|
**GPU:** GPU 0 (~57GB VRAM)
|
||||||
|
**Decodare:** 1+4+0+0+1 = Prod + AI + TextInf + vLLM + instance1
|
||||||
|
|
||||||
|
#### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/v1/models` | List loaded model info |
|
||||||
|
| `POST` | `/v1/chat/completions` | OpenAI-compatible chat (text + vision) |
|
||||||
|
| `POST` | `/v1/completions` | Text completions |
|
||||||
|
| `GET` | `/health` | Health check |
|
||||||
|
| `GET` | `/version` | vLLM version |
|
||||||
|
|
||||||
|
#### Quick Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check model
|
||||||
|
curl http://localhost:14001/v1/models | jq '.data[0].id'
|
||||||
|
# Output: "qwen3.5"
|
||||||
|
|
||||||
|
# Text chat completion
|
||||||
|
curl -X POST http://localhost:14001/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "qwen3.5",
|
||||||
|
"messages": [{"role": "user", "content": "Hello!"}],
|
||||||
|
"max_tokens": 50
|
||||||
|
}'
|
||||||
|
|
||||||
|
# Vision chat (with image URL)
|
||||||
|
curl -X POST http://localhost:14001/v1/chat/completions \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "qwen3.5",
|
||||||
|
"messages": [{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{"type": "text", "text": "What is in this image?"},
|
||||||
|
{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,..."}}
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4️⃣ LLM Inference API (LLM Router)
|
||||||
|
|
||||||
|
**Port:** `14011`
|
||||||
|
**Base URL:** `http://localhost:14011`
|
||||||
|
**Purpose:** Unified LLM inference gateway (routes to vLLM, LiteLLM, llamacpp)
|
||||||
|
**Decodare:** 1+4+0+1+1 = Prod + AI + TextInf + llama.cpp + instance1
|
||||||
|
|
||||||
|
#### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/health` | Health check |
|
||||||
|
| `GET` | `/ready` | Readiness probe |
|
||||||
|
| `GET` | `/v1/models` | List all models (all backends) |
|
||||||
|
| `GET` | `/v1/models?backend=vllm` | Filter by backend |
|
||||||
|
| `GET` | `/v1/backends` | List available backends |
|
||||||
|
| `POST` | `/v1/chat/completions` | Unified chat completions |
|
||||||
|
| `POST` | `/v1/models/load` | Load model (vLLM/llamacpp) |
|
||||||
|
| `POST` | `/v1/models/unload` | Unload model |
|
||||||
|
|
||||||
|
#### Quick Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:14011/health
|
||||||
|
curl http://localhost:14011/v1/backends
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5️⃣ Embeddings API
|
||||||
|
|
||||||
|
**Port:** `14100` (Prod) / `54100` (Dev)
|
||||||
|
**Base URL:** `http://localhost:14100`
|
||||||
|
**Purpose:** OpenAI-compatible embeddings with multi-backend support (vLLM, llama.cpp)
|
||||||
|
**Model:** BAAI/bge-m3
|
||||||
|
**Decodare:** 1+4+1+0+0 = Prod + AI + Embeddings
|
||||||
|
|
||||||
|
**Backend Servers:**
|
||||||
|
| Port | Server | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| 14101 / 54101 | vLLM Embed | GPU-accelerated embedding server |
|
||||||
|
| 14110 / 54110 | llama.cpp Embed | CPU/GGUF embedding server |
|
||||||
|
|
||||||
|
#### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/health` | Health check (per-backend status) |
|
||||||
|
| `GET` | `/ready` | Readiness probe |
|
||||||
|
| `GET` | `/v1/models` | List embedding models |
|
||||||
|
| `GET` | `/v1/backends` | List available backends |
|
||||||
|
| `POST` | `/v1/embeddings` | **Generate embeddings** (OpenAI-compatible) |
|
||||||
|
|
||||||
|
#### Quick Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:14100/health
|
||||||
|
|
||||||
|
# Generate embeddings
|
||||||
|
curl -X POST http://localhost:14100/v1/embeddings \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"input": ["Hello world", "How are you?"],
|
||||||
|
"model": "BAAI/bge-m3"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6️⃣ Rerank API
|
||||||
|
|
||||||
|
**Port:** `14200` (Prod) / `54200` (Dev)
|
||||||
|
**Base URL:** `http://localhost:14200`
|
||||||
|
**Purpose:** Cohere/Jina-compatible document reranking with multi-backend support
|
||||||
|
**Model:** BAAI/bge-reranker-v2-m3
|
||||||
|
**Decodare:** 1+4+2+0+0 = Prod + AI + Reranking
|
||||||
|
|
||||||
|
**Backend Servers:**
|
||||||
|
| Port | Server | Description |
|
||||||
|
|------|--------|-------------|
|
||||||
|
| 14201 / 54201 | vLLM Rerank | GPU-accelerated reranking server |
|
||||||
|
| 14210 / 54210 | llama.cpp Rerank | CPU/GGUF reranking server |
|
||||||
|
|
||||||
|
#### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/health` | Health check (per-backend status) |
|
||||||
|
| `GET` | `/ready` | Readiness probe |
|
||||||
|
| `GET` | `/v1/models` | List reranking models |
|
||||||
|
| `GET` | `/v1/backends` | List available backends |
|
||||||
|
| `POST` | `/v1/rerank` | **Rerank documents** |
|
||||||
|
| `POST` | `/v2/rerank` | Rerank documents (v2 alias) |
|
||||||
|
|
||||||
|
#### Quick Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:14200/health
|
||||||
|
|
||||||
|
# Rerank documents
|
||||||
|
curl -X POST http://localhost:14200/v1/rerank \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"model": "BAAI/bge-reranker-v2-m3",
|
||||||
|
"query": "What is machine learning?",
|
||||||
|
"documents": [
|
||||||
|
"Machine learning is a subset of AI",
|
||||||
|
"Cats are pets",
|
||||||
|
"Deep learning uses neural networks"
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 DEVELOPMENT SERVICES (5xxxx)
|
||||||
|
|
||||||
|
### 7️⃣ Web API (Fact-checking)
|
||||||
|
|
||||||
|
**Port:** `51100`
|
||||||
|
**Base URL:** `http://localhost:51100`
|
||||||
|
**Purpose:** Web search + evidence extraction for fact-checking
|
||||||
|
**Decodare:** 5+1+1+0+0 = Dev + API + Gateway + instance0
|
||||||
|
|
||||||
|
#### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/health` | Health check |
|
||||||
|
| `GET` | `/ready` | Readiness probe |
|
||||||
|
| `POST` | `/v1/gather` | **Main: Complete fact-check pipeline** |
|
||||||
|
| `POST` | `/v1/search` | Web search only (Brave API) |
|
||||||
|
| `POST` | `/v1/fetch` | Fetch URLs with fallback |
|
||||||
|
|
||||||
|
#### Quick Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:51100/health
|
||||||
|
|
||||||
|
# Fact-check pipeline
|
||||||
|
curl -X POST http://localhost:51100/v1/gather \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"claim": "Romania had highest GDP growth in EU 2024",
|
||||||
|
"max_search_results": 5,
|
||||||
|
"extract_snippets": true
|
||||||
|
}' | jq '.evidence[0]'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8️⃣ Audio Transcription API
|
||||||
|
|
||||||
|
**Port:** `54300`
|
||||||
|
**Base URL:** `http://localhost:54300`
|
||||||
|
**Purpose:** Speech-to-text using faster-whisper
|
||||||
|
**Model:** large-v3-turbo (int8 quantization)
|
||||||
|
**GPU:** GPU 1 (7GB / 143GB VRAM)
|
||||||
|
**Decodare:** 5+4+3+0+0 = Dev + AI + Audio + vLLM + instance0
|
||||||
|
|
||||||
|
#### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/health` | Health check |
|
||||||
|
| `GET` | `/v1/models` | List Whisper models |
|
||||||
|
| `POST` | `/v1/audio/transcriptions` | **Transcribe audio** (OpenAI-compatible) |
|
||||||
|
|
||||||
|
#### Quick Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:54300/health
|
||||||
|
|
||||||
|
# Transcribe audio
|
||||||
|
curl -X POST http://localhost:54300/v1/audio/transcriptions \
|
||||||
|
-F "file=@audio.mp3" \
|
||||||
|
-F "response_format=json" \
|
||||||
|
| jq '{text, language, duration}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9️⃣ BusterX vLLM (Deepfake Vision)
|
||||||
|
|
||||||
|
**Port:** `54500`
|
||||||
|
**Base URL:** `http://localhost:54500`
|
||||||
|
**Model:** BusterX (Qwen2.5-VL-7B fine-tuned for deepfake)
|
||||||
|
**GPU:** GPU 1 (22GB / 143GB VRAM)
|
||||||
|
**Decodare:** 5+4+5+0+0 = Dev + AI + Vision + vLLM + instance0
|
||||||
|
|
||||||
|
#### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/v1/models` | List loaded model info |
|
||||||
|
| `POST` | `/v1/chat/completions` | Deepfake detection |
|
||||||
|
| `GET` | `/health` | Health check |
|
||||||
|
|
||||||
|
#### Quick Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:54500/v1/models | jq '.data[0].id'
|
||||||
|
# Used internally by video-analysis module
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🔟 Video Analysis API
|
||||||
|
|
||||||
|
**Port:** `54600`
|
||||||
|
**Base URL:** `http://localhost:54600`
|
||||||
|
**Purpose:** Deepfake detection + semantic video analysis
|
||||||
|
**Decodare:** 5+4+6+0+0 = Dev + AI + Video + instance0
|
||||||
|
|
||||||
|
#### Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `GET` | `/health` | Health check |
|
||||||
|
| `POST` | `/analyze/video` | **Deepfake detection** (fast, 16 frames) |
|
||||||
|
| `POST` | `/analyze/video/semantic` | **Semantic analysis** (deep, 144+ frames) |
|
||||||
|
|
||||||
|
#### Quick Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:54600/health
|
||||||
|
|
||||||
|
# Deepfake detection
|
||||||
|
curl -X POST http://localhost:54600/analyze/video \
|
||||||
|
-F "file=@video.mp4" \
|
||||||
|
| jq '{verdict, explanation}'
|
||||||
|
|
||||||
|
# Semantic analysis
|
||||||
|
curl -X POST http://localhost:54600/analyze/video/semantic \
|
||||||
|
-F "file=@video.mp4" \
|
||||||
|
-F "chunk_duration_s=10.0" \
|
||||||
|
-F "frames_per_chunk=24" \
|
||||||
|
| jq '{num_chunks, final_summary}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧪 Complete Health Check Script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
echo "Testing all endpoints..."
|
||||||
|
|
||||||
|
# Production Services
|
||||||
|
echo "=== PRODUCTION ==="
|
||||||
|
curl -s http://localhost:11000/health && echo " ✓ Catalog API (11000)"
|
||||||
|
curl -s http://localhost:14001/health && echo " ✓ Qwen3.5-35B (14001)"
|
||||||
|
curl -s http://localhost:14011/health && echo " ✓ LLM API (14011)"
|
||||||
|
curl -s http://localhost:14100/health && echo " ✓ Embeddings API (14100)"
|
||||||
|
curl -s http://localhost:14200/health && echo " ✓ Rerank API (14200)"
|
||||||
|
|
||||||
|
# Development Services
|
||||||
|
echo "=== DEVELOPMENT ==="
|
||||||
|
curl -s http://localhost:51100/health && echo " ✓ Web API (51100)"
|
||||||
|
curl -s http://localhost:54100/health && echo " ✓ Embeddings API (54100)"
|
||||||
|
curl -s http://localhost:54200/health && echo " ✓ Rerank API (54200)"
|
||||||
|
curl -s http://localhost:54300/health && echo " ✓ Audio API (54300)"
|
||||||
|
curl -s http://localhost:54500/health && echo " ✓ BusterX (54500)"
|
||||||
|
curl -s http://localhost:54600/health && echo " ✓ Video API (54600)"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "All services operational ✅"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 GPU Allocation
|
||||||
|
|
||||||
|
| GPU | Model | VRAM Used | Total | Utilization |
|
||||||
|
|-----|-------|-----------|-------|-------------|
|
||||||
|
| **GPU 0** | Qwen3.5-35B-A3B (~57GB) + Whisper (~2GB) | ~59GB | 143GB | 41% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Port Map Summary
|
||||||
|
|
||||||
|
```
|
||||||
|
PRODUCTION (1xxxx):
|
||||||
|
├── 11000 Catalog API (Main Gateway)
|
||||||
|
├── 14001 Qwen3.5-35B-A3B (Text + Vision LLM)
|
||||||
|
├── 14011 LLM API (LLM Router)
|
||||||
|
├── 14100 Embeddings API (BGE-M3 Embeddings)
|
||||||
|
│ ├── 14101 vLLM Server
|
||||||
|
│ └── 14110 llama.cpp Server
|
||||||
|
├── 14200 Rerank API (BGE Reranker)
|
||||||
|
│ ├── 14201 vLLM Server
|
||||||
|
│ └── 14210 llama.cpp Server
|
||||||
|
|
||||||
|
DEVELOPMENT (5xxxx):
|
||||||
|
├── 51100 Web API (Fact-checking)
|
||||||
|
├── 54100 Embeddings API (BGE-M3 Embeddings)
|
||||||
|
│ ├── 54101 vLLM Server
|
||||||
|
│ └── 54110 llama.cpp Server
|
||||||
|
├── 54200 Rerank API (BGE Reranker)
|
||||||
|
│ ├── 54201 vLLM Server
|
||||||
|
│ └── 54210 llama.cpp Server
|
||||||
|
├── 54300 Audio API (Whisper STT)
|
||||||
|
├── 54500 BusterX (Deepfake Vision)
|
||||||
|
└── 54600 Video API (Video Analysis)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔗 API Documentation Links
|
||||||
|
|
||||||
|
- **LLM Inference:** `modules/llm-inference/API.md`
|
||||||
|
- **Embeddings:** `modules/embeddings/API.md`
|
||||||
|
- **Rerank:** `modules/rerank/API.md`
|
||||||
|
- **Web (Fact-checking):** `modules/web/README.md`
|
||||||
|
- **Video Analysis:** `modules/video-analysis/API.md`
|
||||||
|
- **Audio Transcription:** `modules/audio/API.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Notes
|
||||||
|
|
||||||
|
- All Production services (1xxxx) are meant for external access
|
||||||
|
- Development services (5xxxx) are for internal/testing use
|
||||||
|
- All vLLM backends support OpenAI-compatible API
|
||||||
|
- All services have health checks configured
|
||||||
|
- Port schema follows datacenter convention for easy identification
|
||||||
960
ai_platform/INDEX.md
Normal file
960
ai_platform/INDEX.md
Normal file
|
|
@ -0,0 +1,960 @@
|
||||||
|
# INDEX - Documentatie tehnica completa ml-projects
|
||||||
|
|
||||||
|
Ultima actualizare: 2026-03-18
|
||||||
|
|
||||||
|
Acest fisier descrie fiecare modul, fiecare fisier, fiecare clasa, fiecare functie, fiecare ruta, fiecare container si fiecare port din acest monorepo. Nimic nu este omis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cuprins
|
||||||
|
|
||||||
|
1. [Arhitectura generala](#1-arhitectura-generala)
|
||||||
|
2. [Gateway (nginx)](#2-gateway-nginx---punctul-unic-de-intrare)
|
||||||
|
3. [Catalog API](#3-catalog-api---agregator-de-servicii)
|
||||||
|
4. [LLM Inference](#4-llm-inference---router-llm-unificat)
|
||||||
|
5. [Embeddings](#5-embeddings---api-de-embeddings)
|
||||||
|
6. [Rerank](#6-rerank---api-de-reranking)
|
||||||
|
7. [Audio](#7-audio---transcriere-audio)
|
||||||
|
8. [Video Analysis](#8-video-analysis---analiza-video)
|
||||||
|
9. [Web](#9-web---cautare-web-si-fact-checking)
|
||||||
|
10. [Harta porturilor](#10-harta-completa-a-porturilor)
|
||||||
|
11. [Harta GPU](#11-alocare-gpu)
|
||||||
|
12. [Retea Docker](#12-retea-docker)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Arhitectura generala
|
||||||
|
|
||||||
|
Monorepo cu 8 module independente. Fiecare modul este un pachet Python instalabil cu FastAPI, containerizat in Docker, conectat pe reteaua comuna `didi-network`. Toate comunica prin HTTP intern. Singurul port expus extern este 11000 (gateway nginx).
|
||||||
|
|
||||||
|
Flux tipic de request extern:
|
||||||
|
```
|
||||||
|
Client -> Gateway (nginx :11000) -> Serviciu intern (llm/audio/web/catalog)
|
||||||
|
```
|
||||||
|
|
||||||
|
Flux intern (intre servicii):
|
||||||
|
```
|
||||||
|
Web API -> LLM Inference API -> vLLM (Qwen3.5)
|
||||||
|
Video API -> vLLM (BusterX)
|
||||||
|
Catalog API -> (interogheaza toate celelalte servicii pe /v1/info)
|
||||||
|
```
|
||||||
|
|
||||||
|
Modele ML servite:
|
||||||
|
- Qwen3.5-35B-A3B (text + vision, MoE) - vLLM pe GPU 0
|
||||||
|
- BAAI/bge-m3 (embeddings) - vLLM sau llama.cpp
|
||||||
|
- BAAI/bge-reranker-v2-m3 (reranking) - vLLM sau llama.cpp
|
||||||
|
- Whisper large-v3-turbo (speech-to-text) - faster-whisper pe GPU 0
|
||||||
|
- BusterX / Qwen2.5-VL-7B (deepfake detection) - vLLM pe GPU 1
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Gateway (nginx) - punctul unic de intrare
|
||||||
|
|
||||||
|
**Locatie:** `modules/gateway/`
|
||||||
|
**Container:** `didiAI-gateway`
|
||||||
|
**Port extern:** 11000
|
||||||
|
**Imagine:** `nginx:1.27-alpine`
|
||||||
|
|
||||||
|
Gateway-ul este un reverse proxy nginx care ruteaza toate request-urile catre serviciile interne. Toate rutele (in afara de /health) necesita autentificare Bearer token.
|
||||||
|
|
||||||
|
### Fisiere
|
||||||
|
|
||||||
|
**deploy/nginx.conf.template** - Template nginx cu variabile de mediu
|
||||||
|
|
||||||
|
Defineste 4 upstream-uri:
|
||||||
|
- `llm` -> `didiAI-llm-api:14011`
|
||||||
|
- `audio` -> `didiAI-audio-api:54300`
|
||||||
|
- `web` -> `didiAI-web-api:51100`
|
||||||
|
- `catalog` -> `didiAI-catalog-api:11000`
|
||||||
|
|
||||||
|
Autentificarea: nginx `map` compara header-ul `Authorization` cu `Bearer ${GATEWAY_API_TOKEN}`. Daca nu coincide, returneaza 401 JSON.
|
||||||
|
|
||||||
|
Rute:
|
||||||
|
- `GET /health` - fara autentificare, returneaza `{"status":"ok"}` direct din nginx
|
||||||
|
- `/llm/` -> proxy catre llm upstream, cu SSE streaming (proxy_buffering off, chunked transfer)
|
||||||
|
- `/audio/` -> proxy catre audio upstream, cu body buffer 10M pentru upload-uri mari
|
||||||
|
- `/web/` -> proxy catre web upstream
|
||||||
|
- `/catalog/` -> proxy catre catalog upstream
|
||||||
|
- `/` (orice altceva) -> 404 cu lista rutelor disponibile
|
||||||
|
|
||||||
|
Timeout-uri proxy: connect 60s, send 300s, read 600s.
|
||||||
|
Upload maxim: 500MB (client_max_body_size).
|
||||||
|
Toate request-urile primesc header X-Request-ID generat de nginx.
|
||||||
|
|
||||||
|
**deploy/docker-compose.yml** - Un singur serviciu `gateway`
|
||||||
|
|
||||||
|
Container `didiAI-gateway` pe imaginea `nginx:1.27-alpine`. La pornire, ruleaza `envsubst` care inlocuieste `${GATEWAY_API_TOKEN}` in template si genereaza `nginx.conf` final. Healthcheck cu `wget` pe `/health`.
|
||||||
|
|
||||||
|
**deploy/deploy.sh** - Script bash
|
||||||
|
|
||||||
|
Valideaza variabila `GATEWAY_API_TOKEN` (fail-fast). Suporta actiunile `up`, `down`, `logs`. Incarca `.env` din directorul `deploy/`.
|
||||||
|
|
||||||
|
**deploy/.env.example** - O singura variabila required: `GATEWAY_API_TOKEN`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Catalog API - agregator de servicii
|
||||||
|
|
||||||
|
**Locatie:** `modules/catalog-api/`
|
||||||
|
**Container:** `didiAI-catalog-api`
|
||||||
|
**Port intern:** 11000 (accesat prin gateway la `/catalog/`)
|
||||||
|
|
||||||
|
### Ce face
|
||||||
|
|
||||||
|
Interogheaza periodic sau la cerere endpoint-ul `/v1/info` de pe fiecare serviciu intern (LLM, Audio, Video, Web). Colecteaza metadata (modele disponibile, functii, starea de sanatate) si le expune intr-un singur loc. Genereaza si un OpenAPI spec agregat care combina spec-urile tuturor componentelor.
|
||||||
|
|
||||||
|
### Fisiere sursa
|
||||||
|
|
||||||
|
**src/catalog_api/settings.py**
|
||||||
|
|
||||||
|
Clasa `CatalogSettings(BaseSettings)` cu prefix `CATALOG_`:
|
||||||
|
- `external_url: str` (REQUIRED, fara default) - URL extern pentru OpenAPI
|
||||||
|
- `host`, `port`, `log_level` - setari server (cu default-uri)
|
||||||
|
- `llm_url`, `audio_url`, `video_url`, `web_url` - URL-uri interne Docker ale serviciilor
|
||||||
|
- `llm_external_port`, `audio_external_port`, `video_external_port`, `web_external_port` - porturi externe
|
||||||
|
|
||||||
|
Clasa `Component(BaseSettings)`:
|
||||||
|
- `id`, `url`, `external_url`, `timeout` - metadata despre un serviciu
|
||||||
|
|
||||||
|
Metoda `get_components() -> list[Component]` - construieste lista componentelor configurate, omitand cele cu URL gol (ex: video_url="" dezactiveaza video).
|
||||||
|
|
||||||
|
Instanta globala `settings = CatalogSettings()`.
|
||||||
|
|
||||||
|
**src/catalog_api/app.py**
|
||||||
|
|
||||||
|
Aplicatia FastAPI principala. Constanta `AGGREGATED_OPENAPI_VERSION = "0.1.0"`.
|
||||||
|
|
||||||
|
Rute:
|
||||||
|
|
||||||
|
- `GET /health` -> `health()` - returneaza `{"status": "ok"}`
|
||||||
|
|
||||||
|
- `GET /v1/components` -> `list_components()` - face fetch async la `/v1/info` pe fiecare component din `settings.get_components()`. Adauga `component_id` si `base_url` la fiecare raspuns. Returneaza `{components: [...], total: N, errors: [...]}`. Gestioneaza timeout-uri si erori HTTP per component.
|
||||||
|
|
||||||
|
- `GET /v1/components/{component_id}` -> `get_component(component_id)` - cauta componenta dupa ID, face fetch la `/v1/info`, returneaza metadata. 404 daca ID-ul nu exista.
|
||||||
|
|
||||||
|
- `GET /v1/models` -> `list_models()` - colecteaza array-ul `models` din `/v1/info` al fiecarei componente. Adauga `component_id` si `component_name` la fiecare model. Ignora silentios componentele cu erori.
|
||||||
|
|
||||||
|
- `GET /v1/functions` -> `list_functions()` - la fel ca models, dar extrage array-ul `functions`.
|
||||||
|
|
||||||
|
- `GET /v1/status` -> `get_status()` - verifica conectivitatea cu fiecare componenta. Statusul general: "healthy" (toate ok), "degraded" (unele ok), "unhealthy" (niciuna). Returneaza `{status, components: [...], healthy_count, total_count}`.
|
||||||
|
|
||||||
|
- `GET /v1/openapi` -> `get_aggregated_openapi()` - face fetch la `/openapi.json` de pe fiecare componenta. Combina spec-urile intr-un singur OpenAPI 3.1.0 cu titlul "didiAI - Aggregated ML Services API". Prefixeaza path-urile cu `/{component_id}` si schema-urile cu `{component_id}_` pentru a evita coliziuni.
|
||||||
|
|
||||||
|
- `GET /v1/docs` -> `get_aggregated_swagger_ui()` - pagina HTML cu Swagger UI care incarca `/v1/openapi`.
|
||||||
|
|
||||||
|
- `GET /v1/redoc` -> `get_aggregated_redoc()` - pagina HTML cu ReDoc.
|
||||||
|
|
||||||
|
- `GET /v1/openapi/component/{component_id}` -> `get_component_openapi(component_id)` - returneaza OpenAPI spec-ul brut al unei componente specifice.
|
||||||
|
|
||||||
|
Functii helper:
|
||||||
|
- `_merge_openapi_schemas(base_spec, component_spec, component_id, component_url)` - combina spec-ul unei componente in spec-ul agregat. Prefixeaza path-urile, schema-urile, operationId-urile.
|
||||||
|
- `_update_refs(obj, component_id)` - actualizeaza recursiv referintele `$ref` din obiectele OpenAPI.
|
||||||
|
|
||||||
|
La final, `uvicorn.run()` porneste serverul.
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
**deploy/Dockerfile** - Multi-stage build pe python:3.11-slim cu uv. Port default 11000. CMD: `uvicorn catalog_api.app:app`.
|
||||||
|
|
||||||
|
**deploy/docker-compose.yml** - Serviciu `catalog-api`, container `didiAI-catalog-api`, port 11000, retea `didi-network`. Healthcheck pe `/health`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. LLM Inference - router LLM unificat
|
||||||
|
|
||||||
|
**Locatie:** `modules/llm-inference/`
|
||||||
|
**Containere:** `didiAI-llm-api` (port 14011), `didiAI-vllm-qwen3.5` (port 14001)
|
||||||
|
|
||||||
|
### Ce face
|
||||||
|
|
||||||
|
Gateway unificat pentru inferenta LLM. Primeste cereri OpenAI-compatibile si le ruteaza catre unul din 3 backend-uri: LiteLLM (100+ provideri cloud), vLLM (GPU local), llama.cpp (CPU local). Suporta streaming SSE, retry cu backoff exponential, rate limiting, concurrency limiting, autentificare Bearer, load balancing pentru llama.cpp.
|
||||||
|
|
||||||
|
### Fisiere sursa
|
||||||
|
|
||||||
|
**src/llm_inference/types.py** - Tipuri de baza
|
||||||
|
|
||||||
|
- `BackendType(str, Enum)` - LITELLM, VLLM, LLAMACPP
|
||||||
|
- `ChatMessage(BaseModel)` - role (system/user/assistant/function/tool), content (str sau list multimodal), name optional
|
||||||
|
- `Usage(BaseModel)` - prompt_tokens, completion_tokens, total_tokens
|
||||||
|
- `Choice(BaseModel)` - index, message, finish_reason (raspuns non-streaming)
|
||||||
|
- `Delta(BaseModel)` - role, content (raspuns streaming)
|
||||||
|
- `StreamChoice(BaseModel)` - index, delta, finish_reason
|
||||||
|
- `ModelInfo(BaseModel)` - id, backend, loaded, context_length, capabilities
|
||||||
|
|
||||||
|
**src/llm_inference/schemas.py** - Scheme API
|
||||||
|
|
||||||
|
- `CompletionRequest(BaseModel)` - messages (1-1000), model, temperature (0-2, default 0.7), max_tokens, stream (bool), backend (override optional), top_p, frequency_penalty, presence_penalty, stop. Validator: non-assistant messages trebuie sa aiba content.
|
||||||
|
- `CompletionResponse(BaseModel)` - id, object="chat.completion", created (unix timestamp), model, choices, usage, backend
|
||||||
|
- `CompletionChunk(BaseModel)` - id, object="chat.completion.chunk", created, model, choices (StreamChoice)
|
||||||
|
- `ModelListResponse`, `ModelLoadRequest`, `ModelLoadResponse` - management modele
|
||||||
|
- `BackendHealth`, `HealthResponse`, `ReadinessResponse`, `BackendListResponse` - monitoring
|
||||||
|
|
||||||
|
**src/llm_inference/config.py** - Configurare Pydantic Settings
|
||||||
|
|
||||||
|
Clasa `LLMSettings(BaseSettings)` cu prefix `LLM_`:
|
||||||
|
|
||||||
|
Campuri REQUIRED (fara default):
|
||||||
|
- `default_backend: Literal["litellm", "vllm", "llamacpp"]`
|
||||||
|
- `enable_vllm: bool`
|
||||||
|
- `enable_llamacpp: bool`
|
||||||
|
- `external_url: str`
|
||||||
|
|
||||||
|
Campuri cu default:
|
||||||
|
- `host="0.0.0.0"`, `port=14011`
|
||||||
|
- `default_model="gpt-3.5-turbo"`
|
||||||
|
- `openrouter_api_key`, `openai_api_key`, `anthropic_api_key` - chei API optionale
|
||||||
|
- `vllm_base_url="http://localhost:14001"`, `vllm_api_key`
|
||||||
|
- `llamacpp_base_url="http://localhost:8102"`, `llamacpp_base_urls` (lista, comma-separated, pt load balancing)
|
||||||
|
- `llamacpp_health_check_interval=30` (interval verificare sanatate servere)
|
||||||
|
- `models_dir="/models"`
|
||||||
|
- `request_timeout=120.0`, `connect_timeout=10.0`
|
||||||
|
- `max_retries=3`, `retry_min_wait=1.0`, `retry_max_wait=60.0`
|
||||||
|
- `rate_limit_rps=10.0`, `rate_limit_burst=20`
|
||||||
|
- `max_concurrent_completions=10`
|
||||||
|
- `api_tokens: frozenset[str] | None` - tokeni Bearer (comma-separated)
|
||||||
|
- `log_level="INFO"`, `log_json=False`
|
||||||
|
|
||||||
|
Proprietati: `auth_enabled`, `llamacpp_urls`.
|
||||||
|
Validatori: `parse_llamacpp_base_urls()` (parseaza string comma-separated in lista), `parse_api_tokens()`, `validate_api_keys()` (avertizeaza daca litellm fara chei API).
|
||||||
|
|
||||||
|
Clasa `SettingsCache` - singleton thread-safe cu `get()`, `clear()`, `set()`.
|
||||||
|
|
||||||
|
**src/llm_inference/exceptions.py** - Exceptii custom
|
||||||
|
|
||||||
|
Baza: `LLMInferenceError(Exception)`. Derivate:
|
||||||
|
- `AuthenticationError` - autentificare esuata
|
||||||
|
- `BackendNotAvailableError(backend, reason)` - backend indisponibil
|
||||||
|
- `BackendNotEnabledError(backend)` - backend neactivat in config
|
||||||
|
- `ModelNotFoundError(model, backend)` - model negasit
|
||||||
|
- `CompletionError(message, backend, model)` - eroare la generare
|
||||||
|
- `ModelLoadError(model, backend, reason)` - eroare la incarcare model
|
||||||
|
- `ModelListError(backend, reason)` - eroare la listare modele
|
||||||
|
- `LLMRateLimitError(message, backend, retry_after)` - rate limit atins
|
||||||
|
- `LLMTimeoutError(message, backend, timeout)` - timeout
|
||||||
|
- `LLMConnectionError(backend, reason)` - conexiune esuata
|
||||||
|
|
||||||
|
**src/llm_inference/retry.py** - Logica de retry
|
||||||
|
|
||||||
|
Importa conditionat exceptiile din `litellm`, `openai`, `httpx`. Defineste doua tupluri globale:
|
||||||
|
- `RETRYABLE_EXCEPTIONS` - RateLimitError, Timeout, ServiceUnavailableError, TimeoutException, ConnectError
|
||||||
|
- `NON_RETRYABLE_EXCEPTIONS` - AuthenticationError, BadRequestError, NotFoundError
|
||||||
|
|
||||||
|
Functii:
|
||||||
|
- `is_retryable_exception(exc)` - verifica daca exceptia e retryable
|
||||||
|
- `extract_retry_after(exc)` - extrage headerul Retry-After din exceptii de rate limit
|
||||||
|
- `translate_exception(exc, backend, model)` - traduce exceptii provider-specifice in exceptii custom
|
||||||
|
- `retry_with_backoff(func, max_retries, min_wait, max_wait, backend, model)` - executa functie async cu retry exponential. Formula: `min_wait * 2^attempt`, capped la max_wait, cu jitter 0-25%. Respecta Retry-After headers.
|
||||||
|
|
||||||
|
**src/llm_inference/logging.py** - Logging structurat
|
||||||
|
|
||||||
|
- `request_id_ctx: ContextVar[str | None]` - propagare request ID prin context
|
||||||
|
- `RequestIdFilter(logging.Filter)` - adauga request_id la log records
|
||||||
|
- `JsonFormatter(logging.Formatter)` - formatare JSON cu timestamp, level, logger, message, request_id, exceptie
|
||||||
|
- `configure_logging(level, json_format)` - configureaza root logger "llm_inference"
|
||||||
|
- `get_logger(name)` - returneaza logger cu prefix "llm_inference."
|
||||||
|
- `set_request_id(request_id)` / `get_request_id()` - management context
|
||||||
|
|
||||||
|
**src/llm_inference/utils.py**
|
||||||
|
|
||||||
|
- `safe_close_stream(stream, logger)` - inchide sigur un stream async (incearca aclose(), fallback pe close())
|
||||||
|
|
||||||
|
**src/llm_inference/image_processing.py** - Procesare imagini multimodale
|
||||||
|
|
||||||
|
Client HTTP global partajat `_http_client` pentru download.
|
||||||
|
|
||||||
|
Functii:
|
||||||
|
- `_get_http_client()` - returneaza/creeaza clientul HTTP partajat
|
||||||
|
- `_guess_mime_type(url, content_type)` - determina MIME type din URL sau Content-Type
|
||||||
|
- `_download_and_encode(url)` - descarca imagine si returneaza ca data URI base64
|
||||||
|
- `_process_content_item(item)` - proceseaza un element de continut (descarca imagini HTTP, lasa base64 si non-HTTP neschimbate)
|
||||||
|
- `_process_text_with_urls(text)` - detecteaza URL-uri de imagini in text plain si le converteste in format multimodal `[{type: "text"}, {type: "image_url"}]`
|
||||||
|
- `process_messages(messages: list[ChatMessage])` - proceseaza toate mesajele, descarcand URL-urile de imagini si convertindu-le in base64
|
||||||
|
|
||||||
|
**src/llm_inference/client.py** - Client de nivel inalt
|
||||||
|
|
||||||
|
Clasa `LLMClient`:
|
||||||
|
- `__init__(settings)` - initializeaza cu settings si BackendRegistry
|
||||||
|
- `complete(messages, model, backend, **kwargs)` - generare chat completion. Proceseaza imagini, rezolva backend-ul, apeleaza backend.complete()
|
||||||
|
- `stream(messages, model, backend, **kwargs)` - generare streaming. Yield-uieste CompletionChunk
|
||||||
|
- `list_models(backend)` - listeaza modele de la un backend sau toate
|
||||||
|
- `load_model(model, backend)` - incarca model pe backend local
|
||||||
|
- `unload_model(model, backend)` - descarca model
|
||||||
|
- `list_backends()` - listeaza tipurile de backend disponibile
|
||||||
|
- `health_check()` - returneaza starea de sanatate per backend
|
||||||
|
- `_resolve_backend_for_model(model)` - interogheaza fiecare backend local sa vada care serveste modelul
|
||||||
|
- `_parse_messages(messages)` - converteste dict-uri in ChatMessage
|
||||||
|
|
||||||
|
**src/llm_inference/cli.py** - Punct de intrare CLI
|
||||||
|
|
||||||
|
Clasa `GracefulShutdown`:
|
||||||
|
- Inregistreaza handlere SIGTERM/SIGINT
|
||||||
|
- Primul semnal: shutdown graceful
|
||||||
|
- Al doilea semnal: exit fortat
|
||||||
|
|
||||||
|
Functia `main()`:
|
||||||
|
- Argumente: `--host`, `--port`, `--workers`, `--reload`, `--graceful-timeout`
|
||||||
|
- Porneste uvicorn cu factory mode `llm_inference.api.app:create_app`
|
||||||
|
|
||||||
|
### Backend-uri
|
||||||
|
|
||||||
|
**src/llm_inference/backends/base.py** - Clasa abstracta `LLMBackend`
|
||||||
|
|
||||||
|
Metode abstracte: `name` (property), `complete()`, `stream()`, `list_models()`
|
||||||
|
Metode concrete (cu default): `load_model()` (NotImplementedError), `unload_model()` (NotImplementedError), `health_check()` (True)
|
||||||
|
|
||||||
|
**src/llm_inference/backends/registry.py** - Registru de backend-uri
|
||||||
|
|
||||||
|
Clasa `BackendRegistry`:
|
||||||
|
- `__init__(settings)` - initializeaza backend-urile activate (LiteLLM mereu, vLLM/llama.cpp optional)
|
||||||
|
- `get(backend_type)` - returneaza instanta backend (default daca None)
|
||||||
|
- `list_backends()` - lista tipurilor disponibile
|
||||||
|
- `is_available(backend_type)` - verifica disponibilitatea
|
||||||
|
|
||||||
|
**src/llm_inference/backends/litellm_backend.py** - Backend LiteLLM
|
||||||
|
|
||||||
|
Clasa `LiteLLMBackend(LLMBackend)`:
|
||||||
|
- Interfata cu 100+ provideri cloud (OpenAI, Anthropic, OpenRouter, Azure, Google, AWS)
|
||||||
|
- `_configure_litellm()` - seteaza cheile API pe modulul litellm
|
||||||
|
- `complete()` - apeleaza `litellm.acompletion()` cu retry_with_backoff
|
||||||
|
- `stream()` - streaming prin `litellm.acompletion(stream=True)`, yield-uieste CompletionChunk
|
||||||
|
- `list_models()` - cache TTL 3600s. Fetcheaza dinamic de la OpenAI si OpenRouter API, fallback pe liste curate (hardcoded)
|
||||||
|
- `health_check()` - verifica conectivitatea la cel putin un provider
|
||||||
|
- Liste fallback: OpenAI (gpt-4o, gpt-4o-mini, gpt-4-turbo, gpt-3.5-turbo), Anthropic (Claude 3.5 Sonnet, 3 Opus, 3 Haiku)
|
||||||
|
|
||||||
|
**src/llm_inference/backends/vllm_backend.py** - Backend vLLM
|
||||||
|
|
||||||
|
Clasa `VLLMBackend(LLMBackend)`:
|
||||||
|
- Conectare la server vLLM prin API OpenAI-compatible (pachetul `openai`)
|
||||||
|
- Creeaza `AsyncOpenAI` client care pointeaza la `vllm_base_url`
|
||||||
|
- `complete()` - `client.chat.completions.create()` cu retry
|
||||||
|
- `stream()` - streaming prin acelasi client, cu safe_close_stream la erori
|
||||||
|
- `list_models()` - `client.models.list()`
|
||||||
|
- `health_check()` - incearca `models.list()`, True daca reuseste
|
||||||
|
|
||||||
|
**src/llm_inference/backends/llamacpp_backend.py** - Backend llama.cpp cu load balancing
|
||||||
|
|
||||||
|
Dataclass `LlamaCppServer`:
|
||||||
|
- `url`, `healthy`, `request_count`, `error_count`, `last_check`, `response_times` (ultimele 50)
|
||||||
|
- Property: `avg_response_ms`, `short_name`
|
||||||
|
|
||||||
|
Clasa `LlamaCppBackend(LLMBackend)`:
|
||||||
|
- Creeaza obiecte `LlamaCppServer` si clienti `AsyncOpenAI` pentru fiecare URL din configurare
|
||||||
|
- Round-robin cu failover automat
|
||||||
|
- `_get_server()` - returneaza urmatorul server sanatos (round-robin). Daca toate sunt nesanatoase, incearca pe toate.
|
||||||
|
- `_mark_unhealthy(server)` / `_mark_healthy(server)` - actualizeaza starea serverului
|
||||||
|
- `_periodic_health_check(interval)` - task async care verifica periodic toate serverele
|
||||||
|
- `_complete_on_server(server, client, messages, model)` - executa completare pe un server specific, masoara timp de raspuns
|
||||||
|
- `complete()` - incearca servere sanatoase round-robin, failover la urmatorul pe erori de conexiune/timeout
|
||||||
|
- `stream()` - streaming cu failover
|
||||||
|
- `list_models()` - interogheaza primul server sanatos
|
||||||
|
- `health_check()` - True daca orice server e disponibil
|
||||||
|
- `get_servers_status()` - returneaza starea tuturor serverelor (URL, health, request_count, error_count, avg_response_ms)
|
||||||
|
|
||||||
|
### API (FastAPI)
|
||||||
|
|
||||||
|
**src/llm_inference/api/app.py** - Factory aplicatie
|
||||||
|
|
||||||
|
Functia `create_app()`:
|
||||||
|
- Creeaza FastAPI cu titlu "LLM Inference API", versiune 0.1.0
|
||||||
|
- Lifespan manager: la startup configureaza logging, initializeaza ConcurrencyLimiter si LLMClient. La shutdown logheaza.
|
||||||
|
- Middleware (ordinea conteaza, primul adaugat = cel mai exterior):
|
||||||
|
1. RateLimitMiddleware - token bucket, exclude /health si /ready
|
||||||
|
2. RequestIdMiddleware - genereaza/extrage X-Request-ID
|
||||||
|
- Routere montate: health, completions (/v1), models (/v1), info (/v1)
|
||||||
|
|
||||||
|
**src/llm_inference/api/dependencies.py** - Dependinte FastAPI
|
||||||
|
|
||||||
|
- `get_client(request)` - returneaza LLMClient din app.state
|
||||||
|
- `get_settings(request)` - returneaza settings din app.state
|
||||||
|
- `verify_bearer_token(request, authorization)` - valideaza token Bearer. Comparatie constant-time cu `hmac.compare_digest`. 401 daca invalid.
|
||||||
|
|
||||||
|
Clasa `ConcurrencyLimiter`:
|
||||||
|
- Limiteaza numarul de completari concurente cu asyncio.Semaphore
|
||||||
|
- `acquire(blocking=False)` - context manager async. Non-blocking (default): 503 imediat daca nu sunt sloturi. Blocking: asteapta slot.
|
||||||
|
- `current_count`, `available` - proprietati de monitorizare
|
||||||
|
|
||||||
|
Functii globale: `init_concurrency_limiter()`, `get_concurrency_limiter()`, `require_completion_slot()` (dependinta FastAPI).
|
||||||
|
|
||||||
|
**src/llm_inference/api/middleware.py** - Middleware
|
||||||
|
|
||||||
|
Clasa `RequestIdMiddleware(BaseHTTPMiddleware)`:
|
||||||
|
- Extrage X-Request-ID din header sau genereaza UUID
|
||||||
|
- Seteaza in context (pt logging), in request.state, si in response headers
|
||||||
|
|
||||||
|
Clasa `TokenBucket`:
|
||||||
|
- Algoritm token bucket pentru rate limiting
|
||||||
|
- `acquire()` - incearca sa consume un token. Returneaza True/False.
|
||||||
|
- `retry_after()` - secunde pana cand un token e disponibil.
|
||||||
|
|
||||||
|
Clasa `RateLimitMiddleware(BaseHTTPMiddleware)`:
|
||||||
|
- Foloseste TokenBucket. Exclude /health si /ready.
|
||||||
|
- Returneaza 429 Too Many Requests cu header Retry-After cand limita e atinsa.
|
||||||
|
- ATENTIE: rate limiting per-proces, nu distribuit. Fiecare replica are limita proprie.
|
||||||
|
|
||||||
|
**src/llm_inference/api/routes/completions.py** - Ruta de completari
|
||||||
|
|
||||||
|
Endpoint `POST /v1/chat/completions`:
|
||||||
|
- Necesita Bearer token (daca auth activat)
|
||||||
|
- Pentru streaming: creeaza generator SSE care yield-uieste chunks JSON, tine slot de concurenta pe intreaga durata stream-ului, ping la fiecare 15s
|
||||||
|
- Pentru non-streaming: achizitioneaza slot, ruleaza completare, elibereaza slot
|
||||||
|
- Erori: BackendNotAvailableError/BackendNotEnabledError -> 400, CompletionError -> 500
|
||||||
|
|
||||||
|
Functii helper:
|
||||||
|
- `_stream_generator(client, request)` - generator SSE, trimite `[DONE]` la final, eroare ca eveniment SSE
|
||||||
|
- `_stream_with_slot(generator, limiter)` - wrapper care tine slot-ul pe durata stream-ului
|
||||||
|
- `_build_completion_kwargs(request)` - construieste kwargs din CompletionRequest
|
||||||
|
|
||||||
|
**src/llm_inference/api/routes/models.py** - Management modele
|
||||||
|
|
||||||
|
- `GET /v1/models` - query param optional `backend` pentru filtrare
|
||||||
|
- `POST /v1/models/load` - incarca model pe backend local (body: ModelLoadRequest)
|
||||||
|
- `POST /v1/models/unload` - descarca model
|
||||||
|
- `GET /v1/backends` - listeaza backend-urile disponibile
|
||||||
|
|
||||||
|
**src/llm_inference/api/routes/health.py** - Health checks
|
||||||
|
|
||||||
|
- `GET /health` - status per-backend, status general (healthy/degraded/unhealthy)
|
||||||
|
- `GET /ready` - probe Kubernetes, verifica backend-ul default
|
||||||
|
|
||||||
|
**src/llm_inference/api/routes/info.py** - Informatii component
|
||||||
|
|
||||||
|
- `GET /v1/info` - returneaza metadata completa pentru catalog: resource (name, slug, config, auth, rate_limits, tags), models (cu capabilities, provider, endpoint), functions (Chat Completions, List Models, List Backends, Load Model, Unload Model cu input/output schema)
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
**deploy/docker-compose.yml** - Defineste 3 servicii:
|
||||||
|
|
||||||
|
1. `llm-api` (didiAI-llm-api, port 14011) - API-ul FastAPI. Profile: api, vllm. Variabile: backend config, chei API, concurrency.
|
||||||
|
2. `vllm-qwen3.5` (didiAI-vllm-qwen3.5, port 14001) - Server vLLM cu Qwen/Qwen3.5-35B-A3B. Profile: vllm. GPU 0. Image: `vllm/vllm-openai:qwen3_5`. Parametri: max-model-len 32000, gpu-memory-utilization 0.65, enable-prefix-caching, enable-auto-tool-choice (Hermes parser). Healthcheck cu 600s start_period (modelul se incarca lent).
|
||||||
|
3. `llamacpp` (optional) - Image: `ghcr.io/ggml-org/llama.cpp:server`. Profile: llamacpp, full. GGUF model din MODELS_DIR. ctx-size 4096.
|
||||||
|
|
||||||
|
**deploy/Dockerfile** - Multi-stage: python:3.11-slim cu uv. Port intern 14011. CMD: `python -m llm_inference.cli`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Embeddings - API de embeddings
|
||||||
|
|
||||||
|
**Locatie:** `modules/embeddings/`
|
||||||
|
**Containere:** `didiAI-embeddings-api` (port 14100/54100), `didiAI-embeddings-vllm` (port 14101/54101), `didiAI-embeddings-llamacpp` (port 14110/54110)
|
||||||
|
|
||||||
|
### Ce face
|
||||||
|
|
||||||
|
API OpenAI-compatibil de embeddings cu suport pentru doua backend-uri: vLLM (GPU) si llama.cpp (CPU/GGUF). Modelul principal: BAAI/bge-m3 (max 8192 tokeni).
|
||||||
|
|
||||||
|
### Fisiere sursa
|
||||||
|
|
||||||
|
Structura e identica cu llm-inference (acelasi tipar arhitectural). Diferentele principale:
|
||||||
|
|
||||||
|
**src/embeddings/types.py**
|
||||||
|
- `BackendType(str, Enum)` - VLLM, LLAMACPP (fara LITELLM)
|
||||||
|
- `EmbeddingUsage(BaseModel)` - prompt_tokens, total_tokens
|
||||||
|
- `EmbeddingData(BaseModel)` - object="embedding", index, embedding (list[float])
|
||||||
|
- `ModelInfo(BaseModel)` - id, backend, loaded, dimensions, max_input_tokens
|
||||||
|
|
||||||
|
**src/embeddings/schemas.py**
|
||||||
|
- `EmbeddingRequest` - input (list[str] sau str), model, encoding_format ("float"/"base64"), dimensions (optional), backend (override). Validatori: ensure_list() converteste str in list, validate_input() verifica ca input-ul nu e gol.
|
||||||
|
- `EmbeddingResponse` - object="list", data (list[EmbeddingData]), model, usage, backend
|
||||||
|
- `encode_embedding_base64(embedding)` - encodeaza vector embedding ca base64 (little-endian floats)
|
||||||
|
|
||||||
|
**src/embeddings/config.py** - `EmbeddingSettings` cu prefix `EMB_`:
|
||||||
|
- Required: `default_backend`, `enable_vllm`, `enable_llamacpp`, `external_url`
|
||||||
|
- Default-uri: port=54100, vllm_base_url="http://localhost:54101", llamacpp_base_url="http://localhost:54110"
|
||||||
|
- Rate limiting: 20 RPS, burst 40, max 20 concurrent
|
||||||
|
|
||||||
|
**src/embeddings/backends/base.py** - `EmbeddingBackend(ABC)`:
|
||||||
|
- `embed(texts, model, dimensions) -> tuple[list[list[float]], EmbeddingUsage]`
|
||||||
|
- `list_models()`, `health_check()`
|
||||||
|
|
||||||
|
**src/embeddings/backends/vllm_backend.py** - `VLLMEmbeddingBackend`:
|
||||||
|
- Foloseste `AsyncOpenAI` client catre serverul vLLM
|
||||||
|
- `embed()` - `client.embeddings.create()`, returneaza vectori si usage
|
||||||
|
|
||||||
|
**src/embeddings/backends/llamacpp_backend.py** - `LlamaCppEmbeddingBackend`:
|
||||||
|
- Identic cu vLLM dar pointeaza la serverul llama.cpp
|
||||||
|
|
||||||
|
**src/embeddings/client.py** - `EmbeddingClient`:
|
||||||
|
- `embed(texts, model, backend, dimensions)` - genereaza embeddings
|
||||||
|
- `list_models(backend)`, `list_backends()`, `health_check()`
|
||||||
|
|
||||||
|
**src/embeddings/api/routes/embeddings.py** - `POST /v1/embeddings`:
|
||||||
|
- Primeste EmbeddingRequest, apeleaza client.embed()
|
||||||
|
- Erori: 400 (backend invalid), 429 (rate limit), 504 (timeout), 503 (conexiune), 500 (eroare generala)
|
||||||
|
|
||||||
|
Celelalte fisiere (cli.py, logging.py, exceptions.py, middleware.py, dependencies.py, routes/models.py, routes/health.py) sunt structurate identic cu llm-inference, adaptate pentru embeddings.
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
**deploy/docker-compose.yml** - 3 servicii:
|
||||||
|
|
||||||
|
1. `embeddings-api` (didiAI-embeddings-api) - FastAPI API. Profile: api, vllm, llamacpp.
|
||||||
|
2. `vllm-embed` (didiAI-embeddings-vllm) - Image: `vllm/vllm-openai:v0.8.5`. Model: BAAI/bge-m3 (configurabil). Task: embed. gpu-memory-utilization configurable (default 0.50). max-model-len configurable (default 8192). Profile: vllm.
|
||||||
|
3. `llamacpp-embed` (didiAI-embeddings-llamacpp) - Image: `ghcr.io/ggml-org/llama.cpp:server`. Model GGUF. Mod embedding activat. ctx-size 8192, threads 4, parallel 4. Profile: llamacpp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Rerank - API de reranking
|
||||||
|
|
||||||
|
**Locatie:** `modules/rerank/`
|
||||||
|
**Containere:** `didiAI-rerank-api` (port 14200/54200), `didiAI-rerank-vllm` (port 14201/54201), `didiAI-rerank-llamacpp` (port 14210/54210)
|
||||||
|
|
||||||
|
### Ce face
|
||||||
|
|
||||||
|
API compatibil Cohere/Jina pentru reranking documente. Primeste un query si o lista de documente, returneaza documentele sortate dupa relevanta cu scoruri. Doua backend-uri: vLLM (GPU) si llama.cpp (CPU).
|
||||||
|
|
||||||
|
### Fisiere sursa
|
||||||
|
|
||||||
|
Structura identica cu embeddings. Diferente specifice:
|
||||||
|
|
||||||
|
**src/rerank/types.py**
|
||||||
|
- `RerankUsage(BaseModel)` - total_tokens
|
||||||
|
- `RerankResult(BaseModel)` - index (pozitia originala), relevance_score, document (optional)
|
||||||
|
- `ModelInfo` - id, backend, loaded, max_input_tokens
|
||||||
|
|
||||||
|
**src/rerank/schemas.py**
|
||||||
|
- `RerankRequest` - model, query (min_length=1), documents (1-1000, fara stringuri goale), top_n (optional), return_documents (bool, default False), backend (override)
|
||||||
|
- `RerankResponse` - id (generat: "rerank-{uuid12}"), model, results (list[RerankResult]), usage, backend
|
||||||
|
|
||||||
|
**src/rerank/config.py** - `RerankSettings` cu prefix `RERANK_`:
|
||||||
|
- Required: `default_backend`, `enable_vllm`, `enable_llamacpp`, `external_url`
|
||||||
|
- Default-uri: port=54200, vllm_base_url="http://localhost:54201", llamacpp_base_url="http://localhost:54210"
|
||||||
|
- Rate limiting: 20 RPS, burst 50, max 20 concurrent
|
||||||
|
|
||||||
|
**src/rerank/backends/vllm_backend.py** - `VLLMRerankBackend`:
|
||||||
|
- Foloseste `httpx.AsyncClient` pentru POST la `/rerank` (nu API OpenAI)
|
||||||
|
- `rerank(query, documents, model, top_n)` - trimite cerere, parseaza rezultatele, returneaza `[(index, score), ...]` si usage
|
||||||
|
|
||||||
|
**src/rerank/backends/llamacpp_backend.py** - `LlamaCppRerankBackend`:
|
||||||
|
- POST la `/rerank`. Gestioneaza field-uri alternative: "relevance_score" sau "score".
|
||||||
|
- Sorteaza descrescator dupa scor, aplica top_n.
|
||||||
|
|
||||||
|
**src/rerank/client.py** - `RerankClient`:
|
||||||
|
- `rerank(query, documents, model, backend, top_n, return_documents)` - obtine backend, apeleaza rerank, construieste RerankResult-uri
|
||||||
|
- `list_models()`, `list_backends()`, `health_check()`
|
||||||
|
|
||||||
|
**src/rerank/api/routes/rerank.py** - Doua routere:
|
||||||
|
- `POST /v1/rerank` si `POST /v2/rerank` (alias) - ambele apeleaza `_handle_rerank()` care achizitioneaza slot de concurenta, apeleaza client.rerank()
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
**deploy/docker-compose.yml** - 3 servicii:
|
||||||
|
|
||||||
|
1. `rerank-api` (didiAI-rerank-api). Profile: api, vllm, llamacpp.
|
||||||
|
2. `vllm-rerank` (didiAI-rerank-vllm) - Image: `vllm/vllm-openai:v0.8.5`. Task: score. Model: BAAI/bge-reranker-v2-m3. Profile: vllm. GPU configurable. 300s start_period.
|
||||||
|
3. `llamacpp-rerank` (didiAI-rerank-llamacpp) - Image: `ghcr.io/ggml-org/llama.cpp:server`. Model GGUF. Mod reranking activat. Profile: llamacpp.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Audio - transcriere audio
|
||||||
|
|
||||||
|
**Locatie:** `modules/audio/`
|
||||||
|
**Container:** `didiAI-audio-api` (port 54300)
|
||||||
|
|
||||||
|
### Ce face
|
||||||
|
|
||||||
|
Serviciu speech-to-text folosind faster-whisper (de 4x mai rapid decat Whisper original). API OpenAI-compatibil. Suporta 99+ limbi, detectie automata limba, VAD filtering.
|
||||||
|
|
||||||
|
### Fisiere sursa
|
||||||
|
|
||||||
|
**src/audio/settings.py** - `Settings(BaseSettings)` cu prefix `AUDIO_`:
|
||||||
|
- `model="large-v3-turbo"` - modelul Whisper
|
||||||
|
- `device="cuda"` - cuda sau cpu
|
||||||
|
- `compute_type="int8"` - tip de cuantizare (int8, float16, int8_float16)
|
||||||
|
- `cache_dir="/root/.cache/huggingface"`
|
||||||
|
- `beam_size=5`, `best_of=5`, `temperature=0.0`
|
||||||
|
- `host`, `port=8200`, `log_level`, `external_url` (REQUIRED)
|
||||||
|
- `max_file_size_mb=500`
|
||||||
|
|
||||||
|
**src/audio/schemas.py**
|
||||||
|
- `TranscriptionSegment` - id, seek, start, end, text, tokens, temperature, avg_logprob, compression_ratio, no_speech_prob
|
||||||
|
- `TranscriptionResponse` - text, language, duration, segments (optional, doar pt verbose_json)
|
||||||
|
- `TranscriptionRequest` - model, language, prompt, response_format ("json"/"text"/"verbose_json"), temperature
|
||||||
|
|
||||||
|
**src/audio/transcriber.py**
|
||||||
|
|
||||||
|
Clasa `Transcriber`:
|
||||||
|
- `__init__()` - incarca `WhisperModel` cu model, device, compute_type, cache_dir din settings
|
||||||
|
- `transcribe(audio_path, language, initial_prompt, temperature)` - apeleaza `self.model.transcribe()` cu beam_size, best_of, VAD filter (min_silence 500ms). Colecteaza segmente. Returneaza (text_complet, metadata).
|
||||||
|
- Metadata: language, language_probability, duration, duration_after_vad, all_language_probs, segments
|
||||||
|
|
||||||
|
Functia `get_transcriber()` - singleton, instantiaza Transcriber la primul apel.
|
||||||
|
|
||||||
|
**src/audio/app.py**
|
||||||
|
|
||||||
|
Aplicatie FastAPI "Audio Transcription API".
|
||||||
|
|
||||||
|
La startup (`startup_event`) incarca modelul Whisper in memorie.
|
||||||
|
|
||||||
|
Rute:
|
||||||
|
- `GET /health` -> `{"status": "ok"}`
|
||||||
|
- `GET /v1/models` -> lista cu un singur model (cel configurat), format OpenAI-compatibil
|
||||||
|
- `POST /v1/audio/transcriptions` -> endpoint principal de transcriere
|
||||||
|
- Parametri form: file (UploadFile) SAU url (str), model, language, prompt, response_format, temperature
|
||||||
|
- `_get_audio_content(file, url)` - obtine continut audio din upload sau URL. Valideaza dimensiune contra max_file_size_mb.
|
||||||
|
- `_download_url(url)` - descarca audio de la URL cu httpx
|
||||||
|
- Flux: obtine audio -> salveaza in fisier temporar -> transcrie -> formateaza raspuns -> sterge fisier temp
|
||||||
|
- Formate raspuns: "text" (PlainTextResponse), "json" (TranscriptionResponse), "verbose_json" (cu segmente detaliate)
|
||||||
|
- `GET /v1/info` -> metadata pentru catalog (resource, models, functions)
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
**deploy/Dockerfile** - Bazat pe `nvidia/cuda:12.1.0-runtime-ubuntu22.04`. Instaleaza Python 3.10, ffmpeg. Nu foloseste uv, ci pip direct. Port 54300.
|
||||||
|
|
||||||
|
**deploy/docker-compose.yml** - Serviciu `audio-api`, container `didiAI-audio-api`. GPU 0 (CUDA_VISIBLE_DEVICES=0). Volum pentru cache modele. Profile: api. Start period 60s.
|
||||||
|
|
||||||
|
**deploy/deploy.sh** - Valideaza AUDIO_MODEL, AUDIO_DEVICE, AUDIO_CACHE_DIR. Suporta profile `api` si `api-nginx`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Video Analysis - analiza video
|
||||||
|
|
||||||
|
**Locatie:** `modules/video-analysis/`
|
||||||
|
**Containere:** `didiAI-video-api` (port 54600), `didiAI-video-vllm-buster` (port 54500)
|
||||||
|
|
||||||
|
### Ce face
|
||||||
|
|
||||||
|
Doua functionalitati:
|
||||||
|
1. Detectie deepfake - extrage 16 frame-uri uniforme, le trimite la BusterX (model fine-tuned pe Qwen2.5-VL-7B), obtine verdict REAL/FAKE/INCONCLUSIVE
|
||||||
|
2. Analiza semantica - divide video-ul in chunk-uri temporale (default 10s), extrage 24 frame-uri/chunk, descrie fiecare chunk cu LLM vision, optional agrega intr-un summary final
|
||||||
|
|
||||||
|
### Fisiere sursa
|
||||||
|
|
||||||
|
**src/video_analysis/settings.py** - `Settings(BaseSettings)` cu prefix `VIDEO_ANALYSIS_`:
|
||||||
|
|
||||||
|
Required:
|
||||||
|
- `vllm_base_url` - URL server vLLM pt deepfake (ex: http://vllm-buster:8000)
|
||||||
|
- `vllm_model` - nume model (ex: "busterx")
|
||||||
|
- `runs_dir` - director artefacte
|
||||||
|
- `external_url` - URL extern OpenAPI
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
- `semantic_vllm_base_url`, `semantic_vllm_model` - vLLM separat pt analiza semantica
|
||||||
|
- `frames=16` - nr frame-uri pt sampling uniform (1-64)
|
||||||
|
- `max_side=960` - dimensiune maxima frame (100-2048)
|
||||||
|
- `jpeg_quality=85` - calitate JPEG (1-100)
|
||||||
|
- `max_tokens=750`, `temperature=1e-6`, `repetition_penalty=1.05`
|
||||||
|
- `analysis_prompt` - prompt pt deepfake ("analyze whether...")
|
||||||
|
- `semantic_prompt` - prompt pt descriere chunk
|
||||||
|
- `aggregation_prompt_template` - template pt agregare
|
||||||
|
- `semantic_chunk_duration_s=10.0`, `semantic_frames_per_chunk=24`
|
||||||
|
- `semantic_enable_aggregation=True`
|
||||||
|
- `semantic_aggregation_model="qwen3.5"` - modelul pt agregare (LLM text, nu vision)
|
||||||
|
- `semantic_llm_base_url` - URL LLM text pt agregare
|
||||||
|
|
||||||
|
Suporta configurare din `deploy/config.yaml` (YAML), cu override din variabile de mediu.
|
||||||
|
|
||||||
|
**src/video_analysis/video_sampling.py** - Utilitare pentru sampling frame-uri
|
||||||
|
|
||||||
|
- `get_video_props(cap)` - extrage total_frames, fps, duration_s, width, height din cv2.VideoCapture
|
||||||
|
- `compute_uniform_indices(total, num_frames)` - calculeaza indici uniformi. Formula: `round(i * (total-1) / (num_frames-1))`
|
||||||
|
- `sample_frames_uniform(video_path, num_frames=16)` - deschide video cu OpenCV, selecteaza frame-uri uniform. Daca total_frames necunoscut, citeste pana la 2000 frame-uri si subsampleaza. Returneaza (liste frame-uri, metadata cu timpi si indici)
|
||||||
|
- `sample_frames_chunked(video_path, chunk_duration_s=10.0, frames_per_chunk=24)` - divide video in chunk-uri temporale. Calculeaza nr chunk-uri = ceil(duration/chunk_duration). Pentru fiecare chunk: calculeaza interval temporal, converteste in indici frame, extrage frame-uri (uniform daca chunk > frames_per_chunk). Returneaza (lista de liste de frame-uri, metadata)
|
||||||
|
|
||||||
|
**src/video_analysis/buster_client.py** - Client vision LLM
|
||||||
|
|
||||||
|
- `frame_to_data_url_b64jpeg(frame_bgr, max_side, jpeg_quality)` - converteste frame BGR la RGB PIL Image, scaleaza la max_side, encodeaza JPEG, returneaza data URI base64
|
||||||
|
- `call_vllm_chat(base_url, model, data_urls, prompt, max_tokens, temperature, repetition_penalty, timeout_s=180)` - construieste payload cu imagini + text, POST la `/v1/chat/completions`, masoara timpul. Returneaza (response JSON, elapsed_seconds)
|
||||||
|
- `parse_verdict_and_explanation(model_text)` - verifica primele 20 caractere (uppercase) pt prefix verdict. REAL/FAKE/altceva=INCONCLUSIVE.
|
||||||
|
|
||||||
|
**src/video_analysis/schemas.py**
|
||||||
|
- `Verdict = Literal["REAL", "FAKE", "INCONCLUSIVE"]`
|
||||||
|
- `Usage` - prompt_tokens, completion_tokens, total_tokens
|
||||||
|
- `LatencyS` - sampling_time_s, encode_time_s, model_inference_time_s
|
||||||
|
- `Meta` - fps, total_frames, duration_s, sampled, indices, timestamps_s
|
||||||
|
- `AnalyzeResponse` - request_id (UUID), run_dir, verdict, explanation, usage, latency_s, meta
|
||||||
|
- `ChunkResult` - chunk_idx, time_range, description, frames_analyzed, inference_time_s, usage
|
||||||
|
- `SemanticMeta` - fps, total_frames, duration_s, chunk_duration_s, frames_per_chunk, total_frames_sampled
|
||||||
|
- `SemanticAnalysisResponse` - request_id, run_dir, analysis_type="semantic", video_duration_s, num_chunks, chunk_results, final_summary, aggregation_time_s, total_latency_s, meta
|
||||||
|
|
||||||
|
**src/video_analysis/app.py**
|
||||||
|
|
||||||
|
Rute:
|
||||||
|
- `GET /health` -> `{"status": "ok"}`
|
||||||
|
|
||||||
|
- `POST /analyze/video` -> deepfake detection
|
||||||
|
1. Genereaza UUID, creeaza run_dir
|
||||||
|
2. Salveaza video, calculeaza SHA256
|
||||||
|
3. `sample_frames_uniform()` cu settings.frames
|
||||||
|
4. `frame_to_data_url_b64jpeg()` pt fiecare frame
|
||||||
|
5. Salveaza request metadata in JSON
|
||||||
|
6. `call_vllm_chat()` cu data_urls + analysis_prompt
|
||||||
|
7. `parse_verdict_and_explanation()`
|
||||||
|
8. Salveaza result in JSON, returneaza AnalyzeResponse
|
||||||
|
|
||||||
|
- `POST /analyze/video/semantic` -> analiza semantica
|
||||||
|
1. Parametri form: file, chunk_duration_s, frames_per_chunk, enable_aggregation
|
||||||
|
2. Selecteaza semantic vLLM daca configurat, altfel fallback la vLLM principal
|
||||||
|
3. `sample_frames_chunked()` - divide in chunk-uri
|
||||||
|
4. Per chunk: encodeaza frame-uri, call vLLM chat, extrage text, creeaza ChunkResult
|
||||||
|
5. Daca aggregation activat si >1 chunk: construieste prompt cu descrierile chunk-urilor, apeleaza LLM text (semantic_llm_base_url) pt summary final
|
||||||
|
6. Returneaza SemanticAnalysisResponse
|
||||||
|
|
||||||
|
- `GET /v1/info` -> metadata catalog
|
||||||
|
|
||||||
|
Functii helper: `safe_mkdir()`, `write_json()`, `sha256_file()`.
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
**deploy/docker-compose.yml** - 2 servicii:
|
||||||
|
|
||||||
|
1. `vllm-buster` (didiAI-video-vllm-buster, port 54500) - Image: `vllm/vllm-openai:latest`. Model: `l8cv/BusterX_plusplus` (served as "busterx"). GPU 1. max-model-len 32768, gpu-memory-utilization 0.25, prefix caching activat. Profile: api-vllm. 600s start_period.
|
||||||
|
2. `video-analysis-api` (didiAI-video-api, port 54600) - FastAPI. Volum `../runs` montat la `/app/runs`. Profile: api, api-vllm.
|
||||||
|
|
||||||
|
**deploy/Dockerfile** - Multi-stage python:3.11-slim cu uv. Port 54600.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Web - cautare web si fact-checking
|
||||||
|
|
||||||
|
**Locatie:** `modules/web/`
|
||||||
|
**Container:** `didiAI-web-api` (port 51100)
|
||||||
|
|
||||||
|
### Ce face
|
||||||
|
|
||||||
|
Modul complex de fact-checking cu pipeline complet: detectie context -> cautare web (SearXNG) -> extragere continut (HTTP/Playwright/Vision) -> impachetare dovezi (deduplicare, extragere snippete cu LLM, scoring relevanta). Pipeline cu fallback automat si cautare multi-round bazata pe context.
|
||||||
|
|
||||||
|
### Fisiere sursa
|
||||||
|
|
||||||
|
**src/web/config.py** - `WebSettings(BaseSettings)` cu prefix `WEB_`:
|
||||||
|
|
||||||
|
Required:
|
||||||
|
- `searxng_base_url` - URL SearXNG (ex: http://localhost:55100)
|
||||||
|
- `llm_base_url` - URL LLM inference server
|
||||||
|
- `external_url` - URL extern OpenAPI
|
||||||
|
|
||||||
|
Campuri cu default (selectie principala):
|
||||||
|
- `port=51100`, `host="0.0.0.0"`
|
||||||
|
- `vision_model="qwen-vl"`, `text_model="qwen3-235b"` - modele LLM
|
||||||
|
- `llm_api_key`, `openai_api_key`, `anthropic_api_key` - chei API
|
||||||
|
- `fetch_timeout=30`, `fetch_user_agent` - setari HTTP
|
||||||
|
- `browse_timeout=30000`, `browse_viewport_width=1280` - setari Playwright
|
||||||
|
- `vision_max_tokens=2000`, `vision_concurrency=3` - setari Vision
|
||||||
|
- `evidence_max_items=30`, `evidence_dedup_threshold=0.9` - setari Evidence
|
||||||
|
- `rate_limit_rps=10.0`, `rate_limit_burst=20`
|
||||||
|
- `api_tokens: frozenset[str] | None` - auth
|
||||||
|
- `context_detection_enabled=True` - detectie context activata/dezactivata
|
||||||
|
|
||||||
|
**src/web/exceptions.py** - Exceptii custom:
|
||||||
|
- `WebError`, `AuthenticationError`, `ProviderError`, `ProviderNotAvailableError`, `SearchError`, `RateLimitError`, `WebTimeoutError`, `WebConnectionError`
|
||||||
|
|
||||||
|
**src/web/orchestrator.py** - Orchestratorul principal
|
||||||
|
|
||||||
|
Clasa `Orchestrator`:
|
||||||
|
- Proprietati lazy-loaded: `search_client`, `fetch_client`, `browse_client`, `vision_client`, `evidence_packer`, `context_detector`
|
||||||
|
|
||||||
|
Metoda `gather(request, request_id)`:
|
||||||
|
- Ruleaza pipeline-ul complet cu timeout global
|
||||||
|
- Inregistreaza duratele fiecarui stage
|
||||||
|
|
||||||
|
Metoda `_run_pipeline(request, request_id, stages)`:
|
||||||
|
|
||||||
|
Stage 0 - Context Detection (`_run_context_stage`):
|
||||||
|
- Analizeaza claim-ul cu LLM-ul local
|
||||||
|
- Detecteaza tara, limba, entitati, genereaza query-uri optimizate
|
||||||
|
|
||||||
|
Stage 1 - Search (`_run_search_stage`):
|
||||||
|
- Cautare multi-round (cand contextul e disponibil):
|
||||||
|
- Round 1: surse oficiale + media din tara detectata
|
||||||
|
- Round 2: surse internationale de fact-checking
|
||||||
|
- Round 3: cautare normala nerestrictata
|
||||||
|
- Rezultatele se combina si se deduplica
|
||||||
|
|
||||||
|
Stage 2 - Fetch (`_run_fetch_stage`):
|
||||||
|
- Lant de fallback: HTTP fetch -> Browse (Playwright) -> Vision (screenshot + LLM)
|
||||||
|
- Conditii de escaladare:
|
||||||
|
- Text extras prea scurt (< `fetch_min_text_length`)
|
||||||
|
- Pagina necesita JavaScript (detectat prin indicatori SPA)
|
||||||
|
- Erori HTTP 401/403
|
||||||
|
- URL-uri PDF sunt sarite complet
|
||||||
|
|
||||||
|
Stage 3 - Evidence (`_run_evidence_stage`):
|
||||||
|
- Deduplicare, extragere snippete, scoring relevanta
|
||||||
|
|
||||||
|
**src/web/search/searxng.py** - Client SearXNG
|
||||||
|
|
||||||
|
Clasa `SearXNGClient`:
|
||||||
|
- `search(request, request_id)` - executa query-uri in paralel, combina rezultatele
|
||||||
|
- `_search_single(query, ...)` - cautare singura cu rate limiting
|
||||||
|
- `_build_query(query, site_allowlist, site_blocklist)` - adauga filtre de site (format `site:example.com`)
|
||||||
|
- `_execute_request(params, ...)` - cu retry si backoff exponential
|
||||||
|
- `_parse_results(data, query)` - extrage SearchResult din raspunsul SearXNG
|
||||||
|
- `image_search(request, request_id)` - cautare imagini prin SearXNG
|
||||||
|
- `health_check()` - probe /healthz
|
||||||
|
|
||||||
|
**src/web/fetch/client.py** - Client HTTP
|
||||||
|
|
||||||
|
Clasa `FetchClient`:
|
||||||
|
- `MAX_PAGE_SIZE = 5MB`
|
||||||
|
- `fetch(request, request_id)` - fetch paralel pe URL-uri
|
||||||
|
- `_fetch_single(url, ...)` - fetch cu extragere continut
|
||||||
|
- `_extract_content(html, url)` - extragere text cu 3 nivele de fallback:
|
||||||
|
1. readability-lxml (calitate cea mai buna)
|
||||||
|
2. BeautifulSoup4 (fallback)
|
||||||
|
3. Regex (ultima sansa)
|
||||||
|
- `_detect_javascript_required(html, text)` - detecteaza pagini JS-heavy: "enable javascript", `<noscript>`, indicatori SPA (react-root, ng-app, __next)
|
||||||
|
|
||||||
|
**src/web/browse/client.py** - Client Playwright
|
||||||
|
|
||||||
|
Clasa `BrowseClient`:
|
||||||
|
- `browse(request, request_id)` - navigare paralela cu browser headless
|
||||||
|
- `_browse_single(url, ...)` - navigare cu asteptare continut dinamic (networkidle, selectori custom), screenshot optional
|
||||||
|
- `_extract_content(page)` - manipulare DOM + extragere text
|
||||||
|
- Suport: data publicare, URL canonic, screenshot base64
|
||||||
|
|
||||||
|
**src/web/vision/client.py** - Client Vision LLM
|
||||||
|
|
||||||
|
Clasa `VisionClient`:
|
||||||
|
- Foloseste `LLMProviderChain` pt fallback provider (local -> OpenAI -> Anthropic)
|
||||||
|
- `extract(request, request_id)` - extragere paralela
|
||||||
|
- `_extract_single(url, ...)` - screenshot + apel vision LLM
|
||||||
|
- `_call_vision_llm(messages, provider, ...)` - apeleaza modelul vision cu imagini
|
||||||
|
- `_extract_images(page)` - analizeaza imaginile de pe pagina
|
||||||
|
|
||||||
|
**src/web/evidence/packer.py** - Impachetare dovezi
|
||||||
|
|
||||||
|
Clasa `EvidencePacker`:
|
||||||
|
|
||||||
|
Algoritm de deduplicare:
|
||||||
|
- SimHash fingerprinting (64-bit) pentru comparare rapida O(n)
|
||||||
|
- Distanta Hamming ca prag de candidati
|
||||||
|
- SequenceMatcher pentru comparare precisa
|
||||||
|
- Multi-nivel: hash exact SHA256, SimHash, similaritate precisa (ratio lungime, prefix/sufix, shingles pt texte lungi)
|
||||||
|
|
||||||
|
Metode:
|
||||||
|
- `pack(request, request_id)` - pipeline complet: deduplicare -> extragere snippete -> scoring
|
||||||
|
- `_deduplicate(pages)` - deduplicare pe baza de SimHash
|
||||||
|
- `_create_evidence_item(page, claim)` - creeaza EvidenceItem din PageContent
|
||||||
|
- `_extract_snippet_llm(text, claim)` - extrage snippet relevant cu LLM
|
||||||
|
- `_extract_snippet_and_score_llm(text, claim)` - snippet + scor relevanta intr-un singur apel
|
||||||
|
- `_score_relevance(text, claim)` - scoring relevanta cu LLM
|
||||||
|
- `_score_credibility_simple(url)` - scoring credibilitate pe baza de domeniu (Reuters, BBC, Nature etc. primesc scor mare)
|
||||||
|
- `_extract_snippets_batch(pages, claim)` - grupuri de 2-3 pagini per apel LLM
|
||||||
|
- `_summarize_batch(pages, claim)` - sumarizare in batch
|
||||||
|
|
||||||
|
Tratament special: elimina taguri `<think>` din output-ul modelelor de reasoning.
|
||||||
|
Circuit breaker: cache negativ de 60s daca LLM-ul nu e disponibil.
|
||||||
|
|
||||||
|
**src/web/llm/provider.py** - Lant de provideri LLM
|
||||||
|
|
||||||
|
Clasa `LLMProviderChain`:
|
||||||
|
- 3 provideri in ordine: local (vLLM), OpenAI, Anthropic
|
||||||
|
- `call_chat(messages, provider, max_tokens, temperature)` - apeleaza providerul specificat
|
||||||
|
- `_call_local(messages, ...)` - apel HTTP direct la vLLM-ul local
|
||||||
|
- `_call_openai(messages, ...)` - API OpenAI
|
||||||
|
- `_call_anthropic(messages, ...)` - API Anthropic (converteste formatul mesajelor)
|
||||||
|
- Suport multimodal: data URI-uri si URL-uri de imagini
|
||||||
|
|
||||||
|
**src/web/context/detector.py** - Detectie context
|
||||||
|
|
||||||
|
Clasa `ContextDetector`:
|
||||||
|
- `detect(claim)` - analizeaza claim-ul inainte de cautare
|
||||||
|
- `_detect_with_llm(claim)` - apeleaza LLM local pt a extrage: tara principala (ISO 3166-1), limba, entitati (persoane, institutii, locatii), query-uri optimizate de cautare
|
||||||
|
- `_is_llm_available()` - verifica disponibilitatea LLM-ului cu probe la `/v1/models`, cache negativ 60s
|
||||||
|
- Fallback: returneaza SearchContext gol daca LLM indisponibil
|
||||||
|
|
||||||
|
**src/web/context/sources.py** - Surse pe tari
|
||||||
|
|
||||||
|
Surse predefinite per tara:
|
||||||
|
- RO (Romania): gov.ro, cdep.ro, senat.ro, digi24.ro, hotnews.ro etc.
|
||||||
|
- US (SUA): whitehouse.gov, congress.gov, nytimes.com, apnews.com etc.
|
||||||
|
- Surse fact-check internationale: Reuters, Snopes, PolitiFact, FactCheck.org, FullFact, BBC, AFP, Veridica.ro
|
||||||
|
|
||||||
|
**src/web/validation.py** - Validare URL (protectie SSRF)
|
||||||
|
- `validate_url(url)` - verificari sincrone: schema (http/https), hostname blocklist (localhost, metadata.google.internal, 169.254.169.254), IP-uri private
|
||||||
|
- `validate_url_dns(url)` - verificare DNS async
|
||||||
|
- `validate_urls_async(urls)` - validare DNS in paralel
|
||||||
|
- Blocate: 127.0.0.0/8, 10.0.0.0/8, 192.168.0.0/16, 172.16.0.0/12
|
||||||
|
|
||||||
|
### Scheme (schemas/)
|
||||||
|
|
||||||
|
- `common.py` - FailedUrl, PageImage, PageContent (url, title, text, hash, extraction_method, timestamps), ProviderHealth, HealthResponse, ErrorDetail, ErrorResponse
|
||||||
|
- `search.py` - SearchResult (query, url, title, snippet, rank, site, published_at), SearchRequest (queries, max_results, site_allowlist/blocklist, language, country, freshness, safe_search), SearchResponse
|
||||||
|
- `fetch.py` - FetchRequest (urls, auto_fallback, method, min_text_length, parallel_fetches), FetchPageResult (status_code, content_type, needs_fallback), FetchResponse
|
||||||
|
- `browse.py` - BrowseRequest (urls, wait, timeout, screenshot), BrowsePageResult (final_url, screenshot_base64, viewport), BrowseResponse
|
||||||
|
- `vision.py` - ImageContext, VisionExtractRequest (urls, context_query, model, screenshots, provider), VisionPageResult (extracted_text, images, tokens_used), VisionExtractResponse
|
||||||
|
- `evidence.py` - EvidenceItem (url, title, publisher, snippet, summary, full_text, relevance_score, credibility_score, provenance), EvidencePackRequest (pages, claim, dedup, LLM flags, limits), EvidenceStats, EvidencePackResponse
|
||||||
|
- `gather.py` - GatherRequest (claim 10-1000 chars, search/fetch/evidence options, context detection, timeout), GatherStageResult (stage, success, counts, duration, error), GatherResponse (evidence, stats, search_results, stages, execution_time)
|
||||||
|
- `image_search.py` - ImageSearchResult (image_url, thumbnail, source_url, title, dimensions), ImageSearchRequest, ImageSearchResponse
|
||||||
|
- `context.py` - EntitySet (persons, institutions, locations), SearchContext (primary_country, entities, detected_language, search_queries)
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
Rute:
|
||||||
|
- `GET /health` - starea serviciului
|
||||||
|
- `GET /ready` - readiness probe
|
||||||
|
- `POST /v1/search` - cautare web prin SearXNG
|
||||||
|
- `POST /v1/image-search` - cautare imagini
|
||||||
|
- `POST /v1/fetch` - fetch HTTP cu extragere continut
|
||||||
|
- `POST /v1/gather` - pipeline complet de fact-checking (endpointul principal)
|
||||||
|
- `GET /v1/info` - metadata catalog
|
||||||
|
|
||||||
|
### Deploy
|
||||||
|
|
||||||
|
**deploy/docker-compose.yml** - Serviciu `didiAI-web-api`, port 51100. SHM 2GB (pt Playwright browsers). Profile: api. Depinde de SearXNG si llm-inference.
|
||||||
|
|
||||||
|
**deploy/Dockerfile** - Multi-stage cu python:3.11-slim. Instaleaza browsere Playwright. Creeaza user non-root. Port 51100.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Harta completa a porturilor
|
||||||
|
|
||||||
|
```
|
||||||
|
PRODUCTION (1xxxx):
|
||||||
|
11000 Gateway (nginx) - singurul port expus extern
|
||||||
|
14001 vLLM Qwen3.5-35B-A3B - server LLM text+vision
|
||||||
|
14011 LLM Inference API - router LLM unificat
|
||||||
|
14100 Embeddings API - API embeddings
|
||||||
|
14101 vLLM Embed Server - backend GPU embeddings
|
||||||
|
14110 llama.cpp Embed Server - backend CPU embeddings
|
||||||
|
14200 Rerank API - API reranking
|
||||||
|
14201 vLLM Rerank Server - backend GPU reranking
|
||||||
|
14210 llama.cpp Rerank Server - backend CPU reranking
|
||||||
|
|
||||||
|
DEVELOPMENT (5xxxx):
|
||||||
|
51100 Web API - fact-checking + cautare web
|
||||||
|
54100 Embeddings API Dev
|
||||||
|
54101 vLLM Embed Server Dev
|
||||||
|
54110 llama.cpp Embed Server Dev
|
||||||
|
54200 Rerank API Dev
|
||||||
|
54201 vLLM Rerank Server Dev
|
||||||
|
54210 llama.cpp Rerank Server Dev
|
||||||
|
54300 Audio API - transcriere Whisper
|
||||||
|
54500 BusterX vLLM - server vision deepfake
|
||||||
|
54600 Video Analysis API - analiza video
|
||||||
|
```
|
||||||
|
|
||||||
|
Schema porturi: 5 cifre. Prima cifra: 1=prod, 5=dev. A doua cifra: 1=API/Gateway, 4=LLM/AI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Alocare GPU
|
||||||
|
|
||||||
|
| GPU | Ce ruleaza | VRAM folosit | VRAM total |
|
||||||
|
|-----|-----------|-------------|------------|
|
||||||
|
| GPU 0 | Qwen3.5-35B-A3B (~57GB) + Whisper large-v3-turbo (~2GB) | ~59GB | 143GB |
|
||||||
|
| GPU 1 | BusterX / Qwen2.5-VL-7B (~22GB) | ~22GB | 143GB |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Retea Docker
|
||||||
|
|
||||||
|
Toate containerele sunt pe reteaua externa `didi-network`. Comunicarea interna se face prin DNS Docker (nume containere):
|
||||||
|
|
||||||
|
```
|
||||||
|
didiAI-gateway -> didiAI-llm-api, didiAI-audio-api, didiAI-web-api, didiAI-catalog-api
|
||||||
|
didiAI-llm-api -> didiAI-vllm-qwen3.5
|
||||||
|
didiAI-catalog-api -> didiAI-llm-api, didiAI-audio-api, didiAI-video-api, didiAI-web-api
|
||||||
|
didiAI-web-api -> SearXNG, didiAI-llm-api
|
||||||
|
didiAI-video-api -> didiAI-video-vllm-buster, didiAI-llm-api (pt agregare semantica)
|
||||||
|
didiAI-embeddings-api -> didiAI-embeddings-vllm, didiAI-embeddings-llamacpp
|
||||||
|
didiAI-rerank-api -> didiAI-rerank-vllm, didiAI-rerank-llamacpp
|
||||||
|
```
|
||||||
|
|
||||||
|
Naming convention containere: `didiAI-{modul}-{serviciu}`.
|
||||||
|
|
||||||
|
## Recent Changes (2026-05-05)
|
||||||
|
|
||||||
|
- **Login Keycloak SSO functional la `/admin-ai/`**: realm `didi-admins` (mutat din `didi-clients`), client `ai-platform-dashboard` (creat in didi-admins ca clona), required role `admin`. SSO comun cu admin-dashboard backend (1 login = ambele dashboard-uri).
|
||||||
|
- **AI dashboard env**: `VITE_KEYCLOAK_URL=https://sso.clossers.com`, `VITE_KEYCLOAK_REALM=didi-admins`, `VITE_KEYCLOAK_CLIENT_ID=ai-platform-dashboard`, `VITE_KEYCLOAK_REQUIRED_ROLE=admin`. Dual var pentru build (VITE_*) + runtime (DASHBOARD_*).
|
||||||
|
- **Schema config DB-overridable**: tabel nou `config_schema_override` (auto-creat la startup), helper `_merged_schema(session)` in `routes/config.py`, endpoint-uri admin `GET /api/config/schema/_overrides`, `PUT /api/config/schema/{key}`, `DELETE /api/config/schema/{key}`. Audit trail (action `config.schema.upsert/delete/seed`).
|
||||||
|
- **Migrare automata 98 chei -> DB**: la primul startup, `seed_schema_if_empty()` populeaza tabelul din `KNOWN_KEYS` (idempotent). Codul KNOWN_KEYS ramane fallback daca DB e sters. DB = single source of truth pentru schema acum.
|
||||||
|
- **didi_brain endpoint nou**: `GET /v1/fact_status/due_for_recheck?limit=N&volatility=X` (facts cu next_check_at <= now, nelocked). Plus rate-limit pe `POST /v1/cache/invalidate` (10/h per actor, dry-run free).
|
||||||
402
ai_platform/ML_PROJECTS_DEPLOY_KEYS_AND_REQUIREMENTS.md
Normal file
402
ai_platform/ML_PROJECTS_DEPLOY_KEYS_AND_REQUIREMENTS.md
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
# ML Projects — Deploy Keys, Env & Requirements
|
||||||
|
|
||||||
|
**Generated:** 2026-04-30
|
||||||
|
**Source mașină:** `/home/admin365/ml-projects/`
|
||||||
|
**Arhivă cod:** `/home/admin365/ml-projects-20260424-1013.tar.gz` (264 MB, conține și .env-urile)
|
||||||
|
|
||||||
|
⚠️ **Document conține secrete în clear text.** Tratează-l ca pe un seif —
|
||||||
|
NU pe email/Slack public, NU în repo public, NU pe wiki. Pentru transfer:
|
||||||
|
scp criptat sau message DM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cuprins
|
||||||
|
|
||||||
|
1. [Cerințe sistem (host)](#cerinte-sistem-host)
|
||||||
|
2. [Servicii externe accesabile (VPN/LAN)](#servicii-externe-accesabile-vpnlan)
|
||||||
|
3. [API keys terți](#api-keys-terti)
|
||||||
|
4. [Credentiale interne (DB, tokens generate)](#credentiale-interne-db-tokens-generate)
|
||||||
|
5. [.env files complet, per modul](#env-files-complet-per-modul)
|
||||||
|
6. [Porturi expuse host-side](#porturi-expuse-host-side)
|
||||||
|
7. [Ordine deploy + comenzi](#ordine-deploy--comenzi)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cerinte sistem (host)
|
||||||
|
|
||||||
|
Minim, pe orice mașină pe care vrei să rulezi stack-ul didiAI:
|
||||||
|
|
||||||
|
| Tool | Versiune | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Linux | Ubuntu 22.04+ | Host OS |
|
||||||
|
| Docker Engine | 24.0+ | Container runtime |
|
||||||
|
| Docker Compose | v2 (plugin) | Orchestration |
|
||||||
|
| Python | 3.10+ | Pentru scripturile bootstrap (didi_brain) |
|
||||||
|
| `python3-venv` | match Python ver | Pentru `bootstrap_deploy.sh` |
|
||||||
|
| curl | orice | Health checks |
|
||||||
|
| openssl | orice | Generare passwords |
|
||||||
|
|
||||||
|
Opțional:
|
||||||
|
- **NVIDIA Driver 535+** + **NVIDIA Container Toolkit** — doar pentru mașinile cu GPU (vLLM, Whisper). Dacă nu ai GPU, mașina asta rulează doar componentele CPU (web-api, dashboard, didi-brain, video-api as proxy).
|
||||||
|
|
||||||
|
Spațiu disk:
|
||||||
|
- ~2 GB pentru imagini Docker
|
||||||
|
- ~1 GB pentru postgres data + atomic atoms (crește cu 100 KB/claim ingerat)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Servicii externe accesabile (VPN/LAN)
|
||||||
|
|
||||||
|
Stack-ul depinde de aceste hosts care rulează GPU-side:
|
||||||
|
|
||||||
|
| Service | URL | Folosit de |
|
||||||
|
|---|---|---|
|
||||||
|
| LLM Router (gateway Qwen) | `http://10.11.10.17:14011` | web, didi_brain, dashboard |
|
||||||
|
| vLLM Qwen3.5-35B (direct) | `http://10.11.10.17:14001` | dashboard (provider stats) |
|
||||||
|
| llama.cpp Qwen3.5-397B #1 | `http://10.11.10.18:14001` | LLM router upstream |
|
||||||
|
| llama.cpp Qwen3.5-397B #2 | `http://10.11.10.19:14001` | LLM router upstream |
|
||||||
|
| BGE-M3 embeddings | `http://10.11.10.15:8200` | didi_brain (Atomic auto-embeds) |
|
||||||
|
| BGE-reranker-v2-m3 | `http://10.11.10.15:8100` | didi_brain (gather rerank) |
|
||||||
|
| Whisper STT | (host-local, audio module) | audio module |
|
||||||
|
|
||||||
|
**Ping test rapid înainte de deploy:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for url in http://10.11.10.17:14011/health \
|
||||||
|
http://10.11.10.15:8100/health \
|
||||||
|
http://10.11.10.15:8200/v1/models; do
|
||||||
|
echo -n "$url ... "
|
||||||
|
curl -sf --connect-timeout 5 "$url" > /dev/null && echo OK || echo FAIL
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API keys terti
|
||||||
|
|
||||||
|
**Search providers (premium tier web-api + dashboard live quota):**
|
||||||
|
|
||||||
|
| Provider | Key | Folosit pentru |
|
||||||
|
|---|---|---|
|
||||||
|
| SerpAPI | `8a84f4de853b5ec0f7eca97b30f27db45c576cfde24e979ad219ff8b9ef61ad6` | Google search (premium rotation) |
|
||||||
|
| Tavily | `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` | AI-optimized search |
|
||||||
|
| LinkUp | `60f33e97-6cb0-4dd0-8364-b120f43efac6` | Web content search |
|
||||||
|
| Brave Search | `BSAYcuZE8365Ms-BEj3yPmDjgdx_UAL` | Premium search alternative |
|
||||||
|
|
||||||
|
**LLM providers:**
|
||||||
|
|
||||||
|
| Provider | Key | Folosit pentru |
|
||||||
|
|---|---|---|
|
||||||
|
| OpenRouter | `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` | Premium tier LLM (Gemini Flash) |
|
||||||
|
|
||||||
|
**HuggingFace** (opțional, pentru download modele gated):
|
||||||
|
- `HF_TOKEN=` (gol în .env actual; setați doar dacă descărcați modele gated)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Credentiale interne (DB, tokens generate)
|
||||||
|
|
||||||
|
**Generate per-deploy** — diferă între mașini, NU le copia direct:
|
||||||
|
|
||||||
|
| Var | Valoare actuală | Notă |
|
||||||
|
|---|---|---|
|
||||||
|
| `DASHBOARD_DB_PASSWORD` | `/8FQ36GKvCnaNNHexJLhaVH0GiPsjWHd` | Postgres dashboard. Generează nou pe altă mașină: `openssl rand -base64 24` |
|
||||||
|
| `GATEWAY_API_TOKEN` | `0x2-m0W5oG7MjZUu5v3-ejBWMExwLeGDv4aRKqBI7bM` | Bearer token pentru gateway nginx |
|
||||||
|
| `ATOMIC_TOKEN` | `at_bsbLAS1tO0wQ0tulyAdJvhzZlNdBqDEyayf_GBzNTUk` | Generat automat de `scripts/02_bootstrap_atomic.py` la primul deploy. **Nu copia — rerularea bootstrap-ului îl regenerează.** |
|
||||||
|
| `POSTGRES_PASSWORD` (didi_brain) | `atomic_dev_changeme` | Default insecure. Schimbă înainte de prod. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## .env files complet, per modul
|
||||||
|
|
||||||
|
### `modules/web/deploy/.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# REQUIRED - Search (SearXNG) - Docker internal hostname
|
||||||
|
WEB_SEARXNG_BASE_URL=http://didiAI-web-searxng:8080
|
||||||
|
WEB_EXTERNAL_URL=http://localhost:51100
|
||||||
|
|
||||||
|
# REQUIRED - LLM Integration
|
||||||
|
WEB_LLM_BASE_URL=http://10.11.10.17:14011
|
||||||
|
WEB_TEXT_MODEL=qwen3.5
|
||||||
|
|
||||||
|
# Vision model - same Qwen3.5 (native multimodal)
|
||||||
|
WEB_VISION_BASE_URL=http://10.11.10.17:14011
|
||||||
|
WEB_VISION_MODEL=qwen3.5
|
||||||
|
|
||||||
|
# OPTIONAL - Premium search providers
|
||||||
|
WEB_SERPAPI_API_KEY=8a84f4de853b5ec0f7eca97b30f27db45c576cfde24e979ad219ff8b9ef61ad6
|
||||||
|
WEB_TAVILY_API_KEY=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
|
||||||
|
WEB_LINKUP_API_KEY=60f33e97-6cb0-4dd0-8364-b120f43efac6
|
||||||
|
WEB_BRAVE_API_KEY=BSAYcuZE8365Ms-BEj3yPmDjgdx_UAL
|
||||||
|
|
||||||
|
# OPTIONAL - OpenRouter (premium tier LLM)
|
||||||
|
WEB_OPENROUTER_API_KEY=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
|
||||||
|
WEB_OPENROUTER_MODEL=google/gemini-3.1-flash-lite-preview
|
||||||
|
|
||||||
|
# OPTIONAL - Dashboard event sink
|
||||||
|
WEB_DASHBOARD_URL=http://didiAI-dashboard:51300
|
||||||
|
|
||||||
|
# OPTIONAL - Brain cache integration (premium tier only)
|
||||||
|
WEB_BRAIN_URL=http://didibrain-api:8090
|
||||||
|
WEB_BRAIN_CACHE_READ_ENABLED=true
|
||||||
|
WEB_BRAIN_INGEST_ENABLED=true
|
||||||
|
WEB_BRAIN_INGEST_MIN_EVIDENCE=3
|
||||||
|
WEB_BRAIN_INGEST_MIN_CREDIBILITY=0.7
|
||||||
|
WEB_BRAIN_INGEST_MIN_EXECUTION_MS=2000
|
||||||
|
WEB_BRAIN_HIT_MIN_EVIDENCE=3
|
||||||
|
WEB_BRAIN_HIT_MIN_RELEVANCE=0.7
|
||||||
|
```
|
||||||
|
|
||||||
|
### `modules/dashboard/deploy/.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database
|
||||||
|
DASHBOARD_DB_USER=dashboard
|
||||||
|
DASHBOARD_DB_PASSWORD=/8FQ36GKvCnaNNHexJLhaVH0GiPsjWHd # GENEREAZĂ NOU pe altă mașină
|
||||||
|
DASHBOARD_DB_NAME=dashboard
|
||||||
|
|
||||||
|
# Server
|
||||||
|
DASHBOARD_EXTERNAL_URL=http://localhost:51300
|
||||||
|
|
||||||
|
# Provider API keys (mirror cu web)
|
||||||
|
DASHBOARD_SERPAPI_API_KEY=8a84f4de853b5ec0f7eca97b30f27db45c576cfde24e979ad219ff8b9ef61ad6
|
||||||
|
DASHBOARD_TAVILY_API_KEY=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
|
||||||
|
DASHBOARD_LINKUP_API_KEY=60f33e97-6cb0-4dd0-8364-b120f43efac6
|
||||||
|
DASHBOARD_BRAVE_API_KEY=BSAYcuZE8365Ms-BEj3yPmDjgdx_UAL
|
||||||
|
DASHBOARD_OPENROUTER_API_KEY=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
|
||||||
|
|
||||||
|
# Upstream URLs
|
||||||
|
DASHBOARD_WEB_API_URL=http://didiAI-web-api:51100
|
||||||
|
DASHBOARD_SEARXNG_URL=http://didiAI-web-searxng:8080
|
||||||
|
DASHBOARD_LLM_API_URL=http://10.11.10.17:14011
|
||||||
|
DASHBOARD_VLLM_QWEN_URL=http://10.11.10.17:14001
|
||||||
|
DASHBOARD_LLAMACPP_URLS=http://10.11.10.18:14001,http://10.11.10.19:14001
|
||||||
|
|
||||||
|
# Retention
|
||||||
|
DASHBOARD_HISTORY_RETENTION_DAYS=30
|
||||||
|
DASHBOARD_PROVIDER_STATS_CACHE_SECONDS=30
|
||||||
|
```
|
||||||
|
|
||||||
|
### `modules/didi_brain/.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# LLM router (single entry point)
|
||||||
|
LLM_ROUTER_URL=http://10.11.10.17:14011
|
||||||
|
LLM_ROUTER_API_KEY=
|
||||||
|
LLM_VLLM_URL=http://localhost:14001
|
||||||
|
LLM_LLAMACPP_URLS=http://10.11.10.18:14001,http://10.11.10.19:14001
|
||||||
|
|
||||||
|
# Models
|
||||||
|
MODEL_REASONING=Qwen3.5-397B-A17B
|
||||||
|
MODEL_REASONING_BACKEND=llamacpp
|
||||||
|
MODEL_FAST=qwen3.5
|
||||||
|
MODEL_FAST_BACKEND=vllm
|
||||||
|
MODEL_FAST_ENABLED=false
|
||||||
|
MODEL_VISION=gemma-3-27b-it
|
||||||
|
MODEL_VISION_URL=http://10.11.10.16:8001
|
||||||
|
MODEL_VISION_ENABLED=false
|
||||||
|
|
||||||
|
# Embeddings
|
||||||
|
EMBEDDING_URL=http://10.11.10.15:8200
|
||||||
|
EMBEDDING_API_KEY=
|
||||||
|
EMBEDDING_MODEL=BAAI/bge-m3
|
||||||
|
EMBEDDING_DIM=1024
|
||||||
|
EMBEDDING_MAX_TOKENS=8192
|
||||||
|
|
||||||
|
# Reranker
|
||||||
|
RERANKER_URL=http://10.11.10.15:8100
|
||||||
|
RERANKER_API_KEY=
|
||||||
|
RERANKER_MODEL=BAAI/bge-reranker-v2-m3
|
||||||
|
|
||||||
|
# Atomic server (token GENERAT de bootstrap; nu copia, rerulează scripts/02)
|
||||||
|
ATOMIC_URL=http://localhost:8088
|
||||||
|
ATOMIC_TOKEN=at_bsbLAS1tO0wQ0tulyAdJvhzZlNdBqDEyayf_GBzNTUk
|
||||||
|
|
||||||
|
# Postgres (schimbă password pe deploy nou)
|
||||||
|
POSTGRES_USER=atomic
|
||||||
|
POSTGRES_PASSWORD=atomic_dev_changeme
|
||||||
|
POSTGRES_DB=atomic
|
||||||
|
POSTGRES_PORT=5434
|
||||||
|
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
### `modules/llm-inference/deploy/.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
LLM_DEFAULT_BACKEND=vllm
|
||||||
|
LLM_ENABLE_VLLM=true
|
||||||
|
LLM_ENABLE_LLAMACPP=true
|
||||||
|
LLM_EXTERNAL_URL=http://localhost:14011
|
||||||
|
LLM_LLAMACPP_BASE_URLS=http://10.11.10.18:14001,http://10.11.10.19:14001
|
||||||
|
|
||||||
|
HF_CACHE_DIR=/home/didiai/.cache/huggingface
|
||||||
|
VLLM_MODEL=Qwen/Qwen3.5-35B-A3B
|
||||||
|
MODELS_DIR=/home/didiai/.cache/huggingface
|
||||||
|
HF_TOKEN=
|
||||||
|
```
|
||||||
|
|
||||||
|
### `modules/audio/deploy/.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
AUDIO_MODEL=large-v3-turbo
|
||||||
|
AUDIO_DEVICE=cuda
|
||||||
|
AUDIO_COMPUTE_TYPE=int8
|
||||||
|
AUDIO_CACHE_DIR=/home/didiai/.cache/huggingface
|
||||||
|
AUDIO_EXTERNAL_URL=http://localhost:54300
|
||||||
|
```
|
||||||
|
|
||||||
|
### `modules/catalog-api/deploy/.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CATALOG_EXTERNAL_URL=http://localhost
|
||||||
|
CATALOG_PORT=11000
|
||||||
|
CATALOG_LLM_URL=http://didiAI-llm-api:14011
|
||||||
|
CATALOG_AUDIO_URL=http://didiAI-audio-api:54300
|
||||||
|
CATALOG_WEB_URL=http://didiAI-web-api:51100
|
||||||
|
CATALOG_VIDEO_URL=
|
||||||
|
```
|
||||||
|
|
||||||
|
### `modules/gateway/deploy/.env`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GATEWAY_API_TOKEN=0x2-m0W5oG7MjZUu5v3-ejBWMExwLeGDv4aRKqBI7bM
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Porturi expuse host-side
|
||||||
|
|
||||||
|
| Port | Serviciu | Container |
|
||||||
|
|---|---|---|
|
||||||
|
| 51100 | Web API (gather, search, fetch) | `didiAI-web-api` |
|
||||||
|
| 51300 | Dashboard UI + API | `didiAI-dashboard` |
|
||||||
|
| 15432 | Dashboard Postgres (debug only) | `didiAI-dashboard-db` |
|
||||||
|
| 54300 | Audio STT proxy | `didiAI-audio-proxy` |
|
||||||
|
| 54600 | Video Analysis | `didiAI-video-api` |
|
||||||
|
| 55100 | SearXNG metasearch | `didiAI-web-searxng` |
|
||||||
|
| 8090 | didi-brain HTTP API | `didibrain-api` |
|
||||||
|
| 8088 | didi-brain Atomic | `didibrain-atomic` |
|
||||||
|
| 5434 | didi-brain Postgres (debug only) | `didibrain-postgres` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ordine deploy + comenzi
|
||||||
|
|
||||||
|
### 1. Pune codul pe mașină
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp /home/admin365/ml-projects-20260424-1013.tar.gz user@new-host:~/
|
||||||
|
ssh user@new-host
|
||||||
|
tar xzf ml-projects-20260424-1013.tar.gz
|
||||||
|
cd ml-projects
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Verifică conectivitatea VPN/LAN
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sf --connect-timeout 5 http://10.11.10.17:14011/health
|
||||||
|
curl -sf --connect-timeout 5 http://10.11.10.15:8100/health
|
||||||
|
```
|
||||||
|
|
||||||
|
Dacă FAIL: stack-ul nu va funcționa — verifică VPN.
|
||||||
|
|
||||||
|
### 3. Verifică / ajustează .env-urile
|
||||||
|
|
||||||
|
Pentru CPU-only host (fără GPU):
|
||||||
|
|
||||||
|
- Editează `modules/web/deploy/.env` → `WEB_LLM_BASE_URL` la IP-ul corect (default `10.11.10.17:14011`)
|
||||||
|
- Editează `modules/didi_brain/.env` → `LLM_ROUTER_URL` la fel
|
||||||
|
- **Generează DB password nou:**
|
||||||
|
```bash
|
||||||
|
NEW_PASS=$(openssl rand -base64 24)
|
||||||
|
sed -i "s|DASHBOARD_DB_PASSWORD=.*|DASHBOARD_DB_PASSWORD=${NEW_PASS}|" modules/dashboard/deploy/.env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Deploy SearXNG (dependență web)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd modules/web/deploy/metasearch
|
||||||
|
docker compose up -d
|
||||||
|
cd -
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Deploy dashboard (postgres + API)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd modules/dashboard/deploy
|
||||||
|
docker compose --profile dashboard up -d
|
||||||
|
cd -
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Deploy web-api
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd modules/web/deploy
|
||||||
|
docker compose --profile api up -d
|
||||||
|
cd -
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Deploy didi-brain (cu bootstrap interactiv)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd modules/didi_brain
|
||||||
|
./scripts/bootstrap_deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Bootstrap-ul:
|
||||||
|
- Build imagine `didibrain-api`
|
||||||
|
- Up postgres + atomic-server + brain-api
|
||||||
|
- Generează ATOMIC_TOKEN nou și-l scrie în `.env`
|
||||||
|
- Seed taxonomy (79 tags)
|
||||||
|
- (Opțional) Import Wikipedia seed + run extraction
|
||||||
|
|
||||||
|
Pentru deploy non-interactiv, dezactivează corpus seed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BRAIN_IMPORT_CORPUS=0 BRAIN_RUN_EXTRACTION=0 ./scripts/bootstrap_deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8. Conectează web-api la rețeaua didibrain
|
||||||
|
|
||||||
|
Web-api rulează pe network `deploy_default`, didi-brain pe `didibrain`. Trebuie unit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker network connect didibrain didiAI-web-api
|
||||||
|
```
|
||||||
|
|
||||||
|
Sau permanent în `modules/web/deploy/docker-compose.yml` (deja făcut: rețeaua `didibrain` e marcată ca `external: true` pe web-api).
|
||||||
|
|
||||||
|
### 9. Smoke test final
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "=== web-api ==="; curl -sf http://localhost:51100/health
|
||||||
|
echo "=== dashboard ==="; curl -sf http://localhost:51300/health
|
||||||
|
echo "=== brain ==="; curl -sf http://localhost:8090/health
|
||||||
|
echo "=== brain cache test ==="
|
||||||
|
curl -sf -X POST http://localhost:8090/v1/gather \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"claim":"vaccines cause autism","max_evidence":3,"run_nli":false}' | jq '.brain_meta.cache_status'
|
||||||
|
```
|
||||||
|
|
||||||
|
Așteptat: `"HIT"` pe vaccines (e în corpus seed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pe scurt — ce trebuie pe noua mașină
|
||||||
|
|
||||||
|
**Copy-paste:**
|
||||||
|
- Toate API keys din secțiunea [API keys terti](#api-keys-terti) — identice pe orice deploy
|
||||||
|
- Toate URL-urile interne (`10.11.10.*`) — dacă VPN-ul e același
|
||||||
|
|
||||||
|
**Generează nou:**
|
||||||
|
- `DASHBOARD_DB_PASSWORD` (random 24 chars)
|
||||||
|
- `ATOMIC_TOKEN` (auto, prin bootstrap script)
|
||||||
|
- `GATEWAY_API_TOKEN` (random; doar dacă deploy-ezi gateway-ul)
|
||||||
|
- `POSTGRES_PASSWORD` didi_brain (default insecure, schimbă în prod)
|
||||||
|
|
||||||
|
**Configurabil opțional:**
|
||||||
|
- `WEB_BRAIN_*` thresholds (defaults safe)
|
||||||
|
- `DASHBOARD_HISTORY_RETENTION_DAYS` (default 30)
|
||||||
|
- `LOG_LEVEL` (INFO)
|
||||||
407
ai_platform/README.md
Normal file
407
ai_platform/README.md
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
# ML Projects
|
||||||
|
|
||||||
|
A monorepo for machine learning services that **expose ML models via REST APIs**. This is the core purpose of the repository - each module provides production-ready API endpoints for inference, allowing applications to consume ML capabilities over HTTP.
|
||||||
|
|
||||||
|
Each module (LLM inference, RAG, etc.) is an independently deployable service with its own API, Docker configuration, and documentation.
|
||||||
|
|
||||||
|
## Quickstart (CPU-only host)
|
||||||
|
|
||||||
|
Bootstrap the **web + dashboard** stack in one command. This is the recommended path for a host that doesn't have a GPU and points at an LLM endpoint running elsewhere.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd ml-projects
|
||||||
|
./bootstrap.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The interactive script prompts for the GPU host URL, paid search provider keys, and generates a fresh DB password. See [`DEPLOYMENT.md`](./DEPLOYMENT.md) for architecture, manual steps, configuration reference, and troubleshooting.
|
||||||
|
|
||||||
|
### What gets deployed by `bootstrap.sh`
|
||||||
|
|
||||||
|
| Service | Port | Role |
|
||||||
|
|---------------|--------|-------------------------------------------------|
|
||||||
|
| Web API | 51100 | Free (SearXNG) + premium (paid) search pipeline |
|
||||||
|
| Dashboard | 51300 | Monitoring, runtime config, archive, cost |
|
||||||
|
| Dashboard DB | 15432 | PostgreSQL — history + config + audit + archive |
|
||||||
|
|
||||||
|
GPU-dependent services (`llm-inference`, `embeddings`, `rerank`, `audio`, `video-analysis`) remain on a separate GPU host and are called over HTTP. Their URLs are captured in `modules/*/deploy/.env`.
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/
|
||||||
|
├── README.md # This file
|
||||||
|
├── CLAUDE.md # Guidelines for Claude AI assistant
|
||||||
|
├── .pre-commit-config.yaml # Pre-commit hooks
|
||||||
|
├── ruff.toml # Shared linting/formatting config
|
||||||
|
├── modules/
|
||||||
|
│ ├── <module-name>/
|
||||||
|
│ │ ├── pyproject.toml # Module dependencies and metadata
|
||||||
|
│ │ ├── README.md # Module docs (incl. prerequisites)
|
||||||
|
│ │ ├── .env.example # Required environment variables
|
||||||
|
│ │ ├── src/<module_name>/ # Source code (importable package)
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ └── ...
|
||||||
|
│ │ ├── tests/ # Tests
|
||||||
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ └── ...
|
||||||
|
│ │ └── deploy/ # REQUIRED: Deployment directory
|
||||||
|
│ │ ├── deploy.sh # Main deployment script
|
||||||
|
│ │ ├── docker-compose.yml
|
||||||
|
│ │ ├── Dockerfile
|
||||||
|
│ │ └── nginx.conf # Optional: reverse proxy config
|
||||||
|
│ └── ...
|
||||||
|
└── shared/ # Optional shared utilities
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
**Required (all modules):**
|
||||||
|
- **Linux** - Ubuntu 22.04+ or similar distribution (no Windows/macOS support)
|
||||||
|
- **Docker Engine 24.0+** - With Docker Compose V2
|
||||||
|
- **Python 3.10+**
|
||||||
|
- **[uv](https://docs.astral.sh/uv/)** - Fast Python package manager
|
||||||
|
- **Git** - With pre-commit hooks support
|
||||||
|
|
||||||
|
**Optional (for GPU workloads):**
|
||||||
|
- **NVIDIA Driver 535+** - For CUDA 12.x support
|
||||||
|
- **NVIDIA Container Toolkit** - For GPU access in Docker containers
|
||||||
|
|
||||||
|
**Note:** Individual modules may have additional prerequisites (specific hardware, external services, etc.). Check each module's README for module-specific requirements.
|
||||||
|
|
||||||
|
### Initial Setup
|
||||||
|
|
||||||
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd ml-projects
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Install pre-commit hooks:
|
||||||
|
```bash
|
||||||
|
uv tool install pre-commit
|
||||||
|
pre-commit install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Working with a Module
|
||||||
|
|
||||||
|
Each module is independent. Navigate to the module directory to work with it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd modules/<module-name>
|
||||||
|
|
||||||
|
# Create virtual environment and install dependencies
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# Run linting
|
||||||
|
uv run ruff check .
|
||||||
|
uv run ruff format --check .
|
||||||
|
|
||||||
|
# Start services (if docker-compose.yml exists)
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
## Module Guidelines
|
||||||
|
|
||||||
|
### Required Structure
|
||||||
|
|
||||||
|
Every module MUST have:
|
||||||
|
|
||||||
|
| File/Directory | Purpose |
|
||||||
|
|----------------|---------|
|
||||||
|
| `pyproject.toml` | Package metadata, dependencies, and build config |
|
||||||
|
| `README.md` | Module documentation (purpose, prerequisites, usage) |
|
||||||
|
| `API.md` | API documentation (required for modules with HTTP APIs) |
|
||||||
|
| `.env.example` | Required environment variables with documentation |
|
||||||
|
| `src/<module_name>/` | Source code as importable package |
|
||||||
|
| `src/<module_name>/__init__.py` | Package init with version |
|
||||||
|
| `tests/` | Test directory with pytest tests |
|
||||||
|
| `deploy/` | Deployment directory (see below) |
|
||||||
|
|
||||||
|
### Deployment Directory (Required)
|
||||||
|
|
||||||
|
Every module MUST have a `deploy/` directory containing:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `deploy.sh` | Main deployment script with `--help`, validation, up/down/logs |
|
||||||
|
| `docker-compose.yml` | Docker Compose with profiles, health checks, pinned versions |
|
||||||
|
| `Dockerfile` | Container image for the module |
|
||||||
|
|
||||||
|
**Optional deployment files:**
|
||||||
|
- `nginx.conf` - Reverse proxy configuration
|
||||||
|
- `*.conf` - Other service configurations
|
||||||
|
|
||||||
|
**deploy.sh requirements:**
|
||||||
|
- Must validate all required environment variables (fail-fast)
|
||||||
|
- Must support `--help` with documentation
|
||||||
|
- Must handle `up`, `down`, `logs` actions
|
||||||
|
- Must source `.env` file if present
|
||||||
|
- Must NOT use fallback defaults
|
||||||
|
|
||||||
|
### pyproject.toml Requirements
|
||||||
|
|
||||||
|
Every module's `pyproject.toml` must include:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[project]
|
||||||
|
name = "<module-name>"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Brief description"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
# production dependencies
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=4.0",
|
||||||
|
"ruff>=0.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/<module_name>"]
|
||||||
|
|
||||||
|
# Use root ruff.toml - only override if necessary
|
||||||
|
[tool.ruff]
|
||||||
|
extend = "../../ruff.toml"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker/Docker Compose Standards
|
||||||
|
|
||||||
|
- Use `docker-compose.yml` (not `docker-compose.yaml`)
|
||||||
|
- Pin image versions (avoid `latest` tag)
|
||||||
|
- Use environment files (`.env`) for configuration
|
||||||
|
- Document all exposed ports in module README
|
||||||
|
- Include health checks for services
|
||||||
|
- **No fallback defaults** - use `${VAR}` not `${VAR:-default}` (fail-fast on missing config)
|
||||||
|
|
||||||
|
### Configuration Best Practices
|
||||||
|
|
||||||
|
**Never use fallback defaults for configuration variables.** If a required variable isn't set, the application should fail immediately with a clear error message.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Good - fails if OPENAI_API_KEY not set
|
||||||
|
environment:
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
|
||||||
|
# Bad - silently uses empty string
|
||||||
|
environment:
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
||||||
|
```
|
||||||
|
|
||||||
|
This applies to:
|
||||||
|
- Docker Compose environment variables
|
||||||
|
- Python `os.getenv()` calls
|
||||||
|
- Pydantic Settings defaults
|
||||||
|
|
||||||
|
**Why?** Silent misconfiguration leads to hard-to-debug production issues. Fail-fast behavior catches problems at startup.
|
||||||
|
|
||||||
|
### API Documentation (Required for HTTP APIs)
|
||||||
|
|
||||||
|
Modules that expose HTTP endpoints **MUST** have an `API.md` file documenting the API.
|
||||||
|
|
||||||
|
**Required sections:**
|
||||||
|
|
||||||
|
| Section | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| Base URL | Use `{BASE_URL}` variable with common configurations |
|
||||||
|
| Authentication | Auth requirements (Bearer token, API key, or "none") |
|
||||||
|
| Endpoints | All endpoints with method, path, request/response, examples |
|
||||||
|
| Error Responses | HTTP status codes and error format |
|
||||||
|
| Request Headers | Required and optional headers |
|
||||||
|
|
||||||
|
**Optional sections:** Rate Limiting, SDK Examples, Supported Backends
|
||||||
|
|
||||||
|
**Reference:** See `modules/llm-inference/API.md` for a complete example.
|
||||||
|
|
||||||
|
### Port Convention
|
||||||
|
|
||||||
|
Port allocation follows datacenter schema (5-digit ports):
|
||||||
|
|
||||||
|
| Prefix | Environment | Description |
|
||||||
|
|--------|-------------|-------------|
|
||||||
|
| 1xxxx | Production | Production services |
|
||||||
|
| 5xxxx | Development | Development/testing services |
|
||||||
|
|
||||||
|
| Second Digit | Category | Example |
|
||||||
|
|--------------|----------|---------|
|
||||||
|
| x0xxx | Web / Frontend | 10000, 50000 |
|
||||||
|
| x1xxx | API / Gateway | 11000, 51100 |
|
||||||
|
| x4xxx | LLM / AI | 14001 |
|
||||||
|
|
||||||
|
**Current Port Allocation:**
|
||||||
|
|
||||||
|
| Port | Service | Environment |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| 11000 | Catalog API | Production |
|
||||||
|
| 14001 | Qwen3.5-35B-A3B | Production |
|
||||||
|
| 14011 | LLM API Gateway | Production |
|
||||||
|
| 51100 | Web API | Development |
|
||||||
|
| 54300 | Audio API | Development |
|
||||||
|
| 54500 | BusterX | Development |
|
||||||
|
| 54600 | Video API | Development |
|
||||||
|
|
||||||
|
**Guidelines:**
|
||||||
|
- Use 1xxxx for production services
|
||||||
|
- Use 5xxxx for development/testing services
|
||||||
|
- Document your port in ENDPOINTS.md and docker-compose.yml
|
||||||
|
|
||||||
|
## Code Quality Standards
|
||||||
|
|
||||||
|
### Linting and Formatting
|
||||||
|
|
||||||
|
This repo uses **[Ruff](https://docs.astral.sh/ruff/)** for linting and formatting. Configuration is in the root `ruff.toml`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check for issues
|
||||||
|
uv run ruff check .
|
||||||
|
|
||||||
|
# Auto-fix issues
|
||||||
|
uv run ruff check --fix .
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
uv run ruff format .
|
||||||
|
```
|
||||||
|
|
||||||
|
### Type Hints
|
||||||
|
|
||||||
|
Type hints are **required** for all public functions and methods:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Good
|
||||||
|
def process_document(text: str, max_length: int = 512) -> list[str]:
|
||||||
|
...
|
||||||
|
|
||||||
|
# Bad - missing type hints
|
||||||
|
def process_document(text, max_length=512):
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
Tests are **required** for all modules. Use pytest:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# Run with coverage
|
||||||
|
uv run pytest --cov=src/<module_name> --cov-report=term-missing
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
uv run pytest tests/test_specific.py
|
||||||
|
```
|
||||||
|
|
||||||
|
**Minimum requirements:**
|
||||||
|
- All public functions must have tests
|
||||||
|
- Aim for >80% code coverage
|
||||||
|
- Include both unit tests and integration tests where applicable
|
||||||
|
|
||||||
|
### Pre-commit Hooks
|
||||||
|
|
||||||
|
Pre-commit hooks run automatically on `git commit`. They enforce:
|
||||||
|
- Ruff linting and formatting
|
||||||
|
- YAML/TOML/JSON validation
|
||||||
|
- No trailing whitespace
|
||||||
|
- No large files (>1MB)
|
||||||
|
- No private keys committed
|
||||||
|
|
||||||
|
To run manually:
|
||||||
|
```bash
|
||||||
|
pre-commit run --all-files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
### Adding a New Module
|
||||||
|
|
||||||
|
1. Create the module directory structure:
|
||||||
|
```bash
|
||||||
|
mkdir -p modules/<module-name>/src/<module_name>
|
||||||
|
mkdir -p modules/<module-name>/tests
|
||||||
|
mkdir -p modules/<module-name>/deploy
|
||||||
|
touch modules/<module-name>/src/<module_name>/__init__.py
|
||||||
|
touch modules/<module-name>/tests/__init__.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Create `pyproject.toml` following the template above
|
||||||
|
|
||||||
|
3. Create `README.md` documenting:
|
||||||
|
- What the module does
|
||||||
|
- Installation instructions
|
||||||
|
- Usage examples
|
||||||
|
|
||||||
|
4. Create `API.md` if module has HTTP endpoints (see API Documentation section)
|
||||||
|
|
||||||
|
5. Add initial tests
|
||||||
|
|
||||||
|
6. Submit a merge request
|
||||||
|
|
||||||
|
### Merge Request Process
|
||||||
|
|
||||||
|
1. Create a feature branch: `git checkout -b feature/<description>`
|
||||||
|
2. Make changes and ensure all checks pass:
|
||||||
|
```bash
|
||||||
|
pre-commit run --all-files
|
||||||
|
uv run pytest
|
||||||
|
```
|
||||||
|
3. Push and create a merge request
|
||||||
|
4. Request review from at least one team member
|
||||||
|
5. Address review comments
|
||||||
|
6. Squash and merge when approved
|
||||||
|
|
||||||
|
### Code Review Expectations
|
||||||
|
|
||||||
|
Reviewers will check for:
|
||||||
|
- [ ] Code follows repo conventions
|
||||||
|
- [ ] Type hints present on public interfaces
|
||||||
|
- [ ] Tests cover new functionality
|
||||||
|
- [ ] Documentation updated if needed
|
||||||
|
- [ ] API.md included/updated for HTTP endpoints
|
||||||
|
- [ ] No security issues introduced
|
||||||
|
- [ ] Changes are focused (no unrelated modifications)
|
||||||
|
|
||||||
|
## Module Index
|
||||||
|
|
||||||
|
| Module | Port | Description | Status |
|
||||||
|
|--------|------|-------------|--------|
|
||||||
|
| [catalog-api](modules/catalog-api/) | 11000 | Service catalog & discovery gateway | Active |
|
||||||
|
| [llm-inference](modules/llm-inference/) | 14011 | Unified LLM inference with multiple backends | Active |
|
||||||
|
| [audio](modules/audio/) | 54300 | Speech-to-text (Whisper) | Active |
|
||||||
|
| [video-analysis](modules/video-analysis/) | 54600 | Deepfake detection & semantic video analysis | Active |
|
||||||
|
| [web](modules/web/) | 51100 | Web scraping & fact-checking evidence gathering | Active |
|
||||||
|
|
||||||
|
## Port Allocation Summary
|
||||||
|
|
||||||
|
Port allocation follows datacenter schema (5-digit ports):
|
||||||
|
|
||||||
|
| Prefix | Environment |
|
||||||
|
|--------|-------------|
|
||||||
|
| 1xxxx | Production |
|
||||||
|
| 5xxxx | Development |
|
||||||
|
|
||||||
|
**Current Services:**
|
||||||
|
|
||||||
|
| Port | Service | Module | Environment |
|
||||||
|
|------|---------|--------|-------------|
|
||||||
|
| 11000 | Catalog API | catalog-api | Production |
|
||||||
|
| 14001 | vLLM Qwen3.5-35B-A3B | llm-inference | Production |
|
||||||
|
| 14011 | LLM API Gateway | llm-inference | Production |
|
||||||
|
| 51100 | Web API | web | Development |
|
||||||
|
| 54300 | Audio API (Whisper) | audio | Development |
|
||||||
|
| 54500 | BusterX vLLM | video-analysis | Development |
|
||||||
|
| 54600 | Video Analysis API | video-analysis | Development |
|
||||||
|
|
||||||
|
For detailed endpoint documentation, see [ENDPOINTS.md](ENDPOINTS.md).
|
||||||
405
ai_platform/bootstrap.sh
Normal file
405
ai_platform/bootstrap.sh
Normal file
|
|
@ -0,0 +1,405 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# ml-projects bootstrap
|
||||||
|
#
|
||||||
|
# Provisions a fresh CPU-only machine with:
|
||||||
|
# - dashboard (monitoring + runtime config, PostgreSQL-backed)
|
||||||
|
# - web (search + gather, tier-based routing)
|
||||||
|
#
|
||||||
|
# Assumes you already have:
|
||||||
|
# - SearXNG running (or will deploy ./modules/web/deploy/metasearch)
|
||||||
|
# - An LLM endpoint on a GPU machine (vLLM / llama.cpp) reachable by URL
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./bootstrap.sh # interactive (prompts for everything)
|
||||||
|
# ./bootstrap.sh --non-interactive # no prompts, uses existing .env files
|
||||||
|
# ./bootstrap.sh --help
|
||||||
|
# =============================================================================
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# ---------- colors ----------
|
||||||
|
if [[ -t 1 ]]; then
|
||||||
|
C_RESET='\033[0m'
|
||||||
|
C_BOLD='\033[1m'
|
||||||
|
C_RED='\033[31m'
|
||||||
|
C_GREEN='\033[32m'
|
||||||
|
C_YELLOW='\033[33m'
|
||||||
|
C_BLUE='\033[34m'
|
||||||
|
C_CYAN='\033[36m'
|
||||||
|
else
|
||||||
|
C_RESET='' C_BOLD='' C_RED='' C_GREEN='' C_YELLOW='' C_BLUE='' C_CYAN=''
|
||||||
|
fi
|
||||||
|
|
||||||
|
step() { printf "\n${C_BOLD}${C_BLUE}==>${C_RESET} ${C_BOLD}%s${C_RESET}\n" "$1"; }
|
||||||
|
info() { printf " ${C_CYAN}%s${C_RESET}\n" "$1"; }
|
||||||
|
ok() { printf " ${C_GREEN}✓${C_RESET} %s\n" "$1"; }
|
||||||
|
warn() { printf " ${C_YELLOW}⚠${C_RESET} %s\n" "$1"; }
|
||||||
|
fail() { printf " ${C_RED}✗${C_RESET} %s\n" "$1" >&2; }
|
||||||
|
die() { fail "$1"; exit 1; }
|
||||||
|
|
||||||
|
# ---------- args ----------
|
||||||
|
INTERACTIVE=1
|
||||||
|
DEPLOY_SEARXNG=0
|
||||||
|
SKIP_SMOKE=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--non-interactive) INTERACTIVE=0 ;;
|
||||||
|
--deploy-searxng) DEPLOY_SEARXNG=1 ;;
|
||||||
|
--skip-smoke) SKIP_SMOKE=1 ;;
|
||||||
|
--help|-h)
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $0 [OPTIONS]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
--non-interactive Skip prompts. All .env files must already be filled.
|
||||||
|
--deploy-searxng Also deploy SearXNG from modules/web/deploy/metasearch.
|
||||||
|
By default, bootstrap assumes SearXNG is already running.
|
||||||
|
--skip-smoke Skip post-deploy smoke tests.
|
||||||
|
-h, --help Show this help and exit.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$0 Full interactive bootstrap
|
||||||
|
$0 --deploy-searxng Also deploy SearXNG
|
||||||
|
$0 --non-interactive Just deploy using existing .env files
|
||||||
|
EOF
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*) die "Unknown argument: $arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------- prereqs ----------
|
||||||
|
step "Checking prerequisites"
|
||||||
|
|
||||||
|
command -v docker >/dev/null 2>&1 || die "docker not found"
|
||||||
|
docker compose version >/dev/null 2>&1 || die "docker compose v2 not found"
|
||||||
|
ok "docker $(docker --version | awk '{print $3}' | tr -d ',')"
|
||||||
|
ok "docker compose $(docker compose version --short)"
|
||||||
|
|
||||||
|
if ! docker info >/dev/null 2>&1; then
|
||||||
|
die "docker daemon not running or current user has no access"
|
||||||
|
fi
|
||||||
|
ok "docker daemon reachable"
|
||||||
|
|
||||||
|
# ---------- network ----------
|
||||||
|
step "Ensuring 'deploy_default' Docker network exists"
|
||||||
|
|
||||||
|
if docker network inspect deploy_default >/dev/null 2>&1; then
|
||||||
|
ok "deploy_default network exists"
|
||||||
|
else
|
||||||
|
docker network create deploy_default >/dev/null
|
||||||
|
ok "deploy_default network created"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- helpers ----------
|
||||||
|
gen_password() {
|
||||||
|
# URL-safe 24-char password
|
||||||
|
if command -v openssl >/dev/null 2>&1; then
|
||||||
|
openssl rand -base64 24 | tr -d '/+=' | head -c 24
|
||||||
|
else
|
||||||
|
head -c 18 /dev/urandom | base64 | tr -d '/+=' | head -c 24
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt() {
|
||||||
|
# prompt VAR_NAME "Prompt text" "default"
|
||||||
|
local var="$1" label="$2" default="${3:-}" answer=""
|
||||||
|
if [[ $INTERACTIVE -eq 0 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [[ -n "$default" ]]; then
|
||||||
|
read -rp " $label [$default]: " answer
|
||||||
|
answer="${answer:-$default}"
|
||||||
|
else
|
||||||
|
read -rp " $label: " answer
|
||||||
|
fi
|
||||||
|
eval "$var=\"\$answer\""
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_secret() {
|
||||||
|
# same as prompt but stdin is not echoed
|
||||||
|
local var="$1" label="$2" answer=""
|
||||||
|
if [[ $INTERACTIVE -eq 0 ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
read -rsp " $label: " answer; echo
|
||||||
|
eval "$var=\"\$answer\""
|
||||||
|
}
|
||||||
|
|
||||||
|
render_env() {
|
||||||
|
# render_env <example-path> <output-path> <VAR=VALUE ...>
|
||||||
|
# Rewrites lines matching ^#?\s*KEY= to KEY=VALUE. Leaves other lines alone.
|
||||||
|
local example="$1" output="$2"
|
||||||
|
shift 2
|
||||||
|
cp "$example" "$output"
|
||||||
|
for pair in "$@"; do
|
||||||
|
local key="${pair%%=*}"
|
||||||
|
local val="${pair#*=}"
|
||||||
|
# Escape for sed replacement
|
||||||
|
local esc
|
||||||
|
esc=$(printf '%s' "$val" | sed 's/[&/\]/\\&/g')
|
||||||
|
# Match either "KEY=..." or "# KEY=..."
|
||||||
|
if grep -qE "^#?\s*${key}=" "$output"; then
|
||||||
|
sed -i -E "s|^#?\s*${key}=.*|${key}=${esc}|" "$output"
|
||||||
|
else
|
||||||
|
printf '\n%s=%s\n' "$key" "$val" >> "$output"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------- collect config ----------
|
||||||
|
WEB_ENV_PATH="$REPO_ROOT/modules/web/deploy/.env"
|
||||||
|
DASHBOARD_ENV_PATH="$REPO_ROOT/modules/dashboard/deploy/.env"
|
||||||
|
|
||||||
|
if [[ $INTERACTIVE -eq 1 ]]; then
|
||||||
|
step "Collecting configuration"
|
||||||
|
|
||||||
|
info "Press Enter to accept defaults. Leave paid API keys blank to skip."
|
||||||
|
echo
|
||||||
|
|
||||||
|
# LLM on the GPU machine
|
||||||
|
prompt LLM_HOST "GPU host IP or hostname (e.g. 10.11.10.42)" "localhost"
|
||||||
|
prompt LLM_PORT "LLM inference API port" "14011"
|
||||||
|
prompt VLLM_PORT "Raw vLLM port (for dashboard health checks)" "14001"
|
||||||
|
|
||||||
|
LLM_URL="http://${LLM_HOST}:${LLM_PORT}"
|
||||||
|
VLLM_URL="http://${LLM_HOST}:${VLLM_PORT}"
|
||||||
|
ok "LLM URL: $LLM_URL"
|
||||||
|
ok "vLLM URL: $VLLM_URL"
|
||||||
|
|
||||||
|
# SearXNG
|
||||||
|
echo
|
||||||
|
if [[ $DEPLOY_SEARXNG -eq 1 ]]; then
|
||||||
|
SEARXNG_HOST="didiAI-web-searxng"
|
||||||
|
SEARXNG_PORT="8080"
|
||||||
|
info "Will deploy SearXNG internally ($SEARXNG_HOST:$SEARXNG_PORT)"
|
||||||
|
else
|
||||||
|
prompt SEARXNG_HOST "SearXNG container name or host" "didiAI-web-searxng"
|
||||||
|
prompt SEARXNG_PORT "SearXNG port" "8080"
|
||||||
|
fi
|
||||||
|
SEARXNG_URL="http://${SEARXNG_HOST}:${SEARXNG_PORT}"
|
||||||
|
ok "SearXNG URL: $SEARXNG_URL"
|
||||||
|
|
||||||
|
# External URLs
|
||||||
|
echo
|
||||||
|
prompt WEB_EXT "Public-facing Web API URL" "http://localhost:51100"
|
||||||
|
prompt DASH_EXT "Public-facing Dashboard URL" "http://localhost:51300"
|
||||||
|
|
||||||
|
# Paid provider keys
|
||||||
|
echo
|
||||||
|
info "Premium tier API keys (leave blank to skip):"
|
||||||
|
prompt_secret SERPAPI_KEY "SerpAPI key (blank to skip)"
|
||||||
|
prompt_secret TAVILY_KEY "Tavily key"
|
||||||
|
prompt_secret BRAVE_KEY "Brave key"
|
||||||
|
prompt_secret LINKUP_KEY "LinkUp key"
|
||||||
|
prompt_secret OPENROUTER_KEY "OpenRouter key"
|
||||||
|
|
||||||
|
# DB password
|
||||||
|
echo
|
||||||
|
prompt USE_RANDOM_DB_PW "Generate random Postgres password? (y/n)" "y"
|
||||||
|
if [[ "${USE_RANDOM_DB_PW,,}" == "y" ]]; then
|
||||||
|
DB_PASSWORD="$(gen_password)"
|
||||||
|
ok "Generated DB password: $DB_PASSWORD"
|
||||||
|
else
|
||||||
|
prompt_secret DB_PASSWORD "Postgres password for dashboard DB"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
step "Non-interactive mode — using existing .env files"
|
||||||
|
[[ -f "$WEB_ENV_PATH" ]] || die "Missing $WEB_ENV_PATH"
|
||||||
|
[[ -f "$DASHBOARD_ENV_PATH" ]] || die "Missing $DASHBOARD_ENV_PATH"
|
||||||
|
ok "Existing .env files found"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- render .env files (interactive only) ----------
|
||||||
|
if [[ $INTERACTIVE -eq 1 ]]; then
|
||||||
|
step "Rendering .env files"
|
||||||
|
|
||||||
|
render_env \
|
||||||
|
"$REPO_ROOT/modules/web/.env.example" \
|
||||||
|
"$WEB_ENV_PATH" \
|
||||||
|
"WEB_SEARXNG_BASE_URL=$SEARXNG_URL" \
|
||||||
|
"WEB_EXTERNAL_URL=$WEB_EXT" \
|
||||||
|
"WEB_LLM_BASE_URL=$LLM_URL" \
|
||||||
|
"WEB_VISION_BASE_URL=$LLM_URL" \
|
||||||
|
"WEB_DASHBOARD_URL=http://didiAI-dashboard:51300" \
|
||||||
|
${SERPAPI_KEY:+"WEB_SERPAPI_API_KEY=$SERPAPI_KEY"} \
|
||||||
|
${TAVILY_KEY:+"WEB_TAVILY_API_KEY=$TAVILY_KEY"} \
|
||||||
|
${BRAVE_KEY:+"WEB_BRAVE_API_KEY=$BRAVE_KEY"} \
|
||||||
|
${LINKUP_KEY:+"WEB_LINKUP_API_KEY=$LINKUP_KEY"} \
|
||||||
|
${OPENROUTER_KEY:+"WEB_OPENROUTER_API_KEY=$OPENROUTER_KEY"}
|
||||||
|
ok "Wrote $WEB_ENV_PATH"
|
||||||
|
|
||||||
|
render_env \
|
||||||
|
"$REPO_ROOT/modules/dashboard/.env.example" \
|
||||||
|
"$DASHBOARD_ENV_PATH" \
|
||||||
|
"DASHBOARD_DB_PASSWORD=$DB_PASSWORD" \
|
||||||
|
"DASHBOARD_EXTERNAL_URL=$DASH_EXT" \
|
||||||
|
"DASHBOARD_SEARXNG_URL=$SEARXNG_URL" \
|
||||||
|
"DASHBOARD_LLM_API_URL=$LLM_URL" \
|
||||||
|
"DASHBOARD_VLLM_QWEN_URL=$VLLM_URL" \
|
||||||
|
${SERPAPI_KEY:+"DASHBOARD_SERPAPI_API_KEY=$SERPAPI_KEY"} \
|
||||||
|
${TAVILY_KEY:+"DASHBOARD_TAVILY_API_KEY=$TAVILY_KEY"} \
|
||||||
|
${BRAVE_KEY:+"DASHBOARD_BRAVE_API_KEY=$BRAVE_KEY"} \
|
||||||
|
${LINKUP_KEY:+"DASHBOARD_LINKUP_API_KEY=$LINKUP_KEY"} \
|
||||||
|
${OPENROUTER_KEY:+"DASHBOARD_OPENROUTER_API_KEY=$OPENROUTER_KEY"}
|
||||||
|
ok "Wrote $DASHBOARD_ENV_PATH"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- optional SearXNG ----------
|
||||||
|
if [[ $DEPLOY_SEARXNG -eq 1 ]]; then
|
||||||
|
step "Deploying SearXNG"
|
||||||
|
if [[ -d "$REPO_ROOT/modules/web/deploy/metasearch" ]]; then
|
||||||
|
(cd "$REPO_ROOT/modules/web/deploy/metasearch" && docker compose up -d)
|
||||||
|
ok "SearXNG stack up"
|
||||||
|
else
|
||||||
|
warn "modules/web/deploy/metasearch not found — skipping"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- deploy dashboard ----------
|
||||||
|
step "Deploying dashboard (PostgreSQL + UI on port 51300)"
|
||||||
|
|
||||||
|
(cd "$REPO_ROOT/modules/dashboard/deploy" && docker compose --profile dashboard up -d --build)
|
||||||
|
ok "Dashboard containers started"
|
||||||
|
|
||||||
|
info "Waiting for dashboard to become healthy..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sfm 2 http://localhost:51300/health >/dev/null 2>&1; then
|
||||||
|
ok "Dashboard healthy"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
if [[ $i -eq 30 ]]; then
|
||||||
|
fail "Dashboard did not become healthy within 60s"
|
||||||
|
docker logs --tail 50 didiAI-dashboard || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------- deploy web ----------
|
||||||
|
step "Deploying web-api (port 51100)"
|
||||||
|
|
||||||
|
(cd "$REPO_ROOT/modules/web/deploy" && docker compose --profile api up -d --build)
|
||||||
|
ok "Web API containers started"
|
||||||
|
|
||||||
|
info "Waiting for web-api to become healthy..."
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if curl -sfm 2 http://localhost:51100/health >/dev/null 2>&1; then
|
||||||
|
ok "Web API healthy"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
if [[ $i -eq 30 ]]; then
|
||||||
|
fail "Web API did not become healthy within 60s"
|
||||||
|
docker logs --tail 50 didiAI-web-api || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# ---------- create admin user ----------
|
||||||
|
ADMIN_TOKEN=""
|
||||||
|
if [[ $INTERACTIVE -eq 1 ]]; then
|
||||||
|
step "Creating dashboard admin user"
|
||||||
|
|
||||||
|
prompt ADMIN_NAME "Admin username" "admin"
|
||||||
|
prompt ADMIN_EMAIL "Admin email (optional)" ""
|
||||||
|
|
||||||
|
set +e
|
||||||
|
ADMIN_OUTPUT=$(docker exec -i didiAI-dashboard python -m dashboard.cli \
|
||||||
|
create-user "$ADMIN_NAME" \
|
||||||
|
${ADMIN_EMAIL:+--email "$ADMIN_EMAIL"} \
|
||||||
|
--role admin 2>&1)
|
||||||
|
ADMIN_RC=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [[ $ADMIN_RC -eq 0 ]]; then
|
||||||
|
ADMIN_TOKEN=$(echo "$ADMIN_OUTPUT" | grep -oE '[A-Za-z0-9_-]{32,}' | tail -1)
|
||||||
|
ok "Admin user '$ADMIN_NAME' created"
|
||||||
|
else
|
||||||
|
warn "Admin user creation failed (maybe already exists). You can create one later:"
|
||||||
|
warn " docker exec didiAI-dashboard python -m dashboard.cli create-user <name>"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- smoke tests ----------
|
||||||
|
if [[ $SKIP_SMOKE -eq 0 ]]; then
|
||||||
|
step "Running smoke tests"
|
||||||
|
|
||||||
|
# Dashboard
|
||||||
|
if curl -sfm 3 http://localhost:51300/health | grep -q '"status":"healthy"'; then
|
||||||
|
ok "Dashboard /health"
|
||||||
|
else
|
||||||
|
warn "Dashboard /health unexpected response"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Web API
|
||||||
|
if curl -sfm 3 http://localhost:51100/health >/dev/null 2>&1; then
|
||||||
|
ok "Web API /health"
|
||||||
|
else
|
||||||
|
warn "Web API /health failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Providers live endpoint
|
||||||
|
PROV_COUNT=$(curl -sfm 10 http://localhost:51300/api/stats/providers 2>/dev/null |
|
||||||
|
grep -oE '"name"' | wc -l || echo 0)
|
||||||
|
if [[ $PROV_COUNT -gt 0 ]]; then
|
||||||
|
ok "Dashboard sees $PROV_COUNT providers"
|
||||||
|
else
|
||||||
|
warn "Dashboard provider probe returned no data (LLM host may be unreachable)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Free tier search — only if SearXNG is reachable
|
||||||
|
FREE_CODE=$(curl -sf -o /dev/null -w "%{http_code}" --max-time 15 \
|
||||||
|
-X POST http://localhost:51100/v1/search \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"queries":["bootstrap test"],"max_results":3}' || echo "fail")
|
||||||
|
if [[ "$FREE_CODE" == "200" ]]; then
|
||||||
|
ok "Free tier search works"
|
||||||
|
else
|
||||||
|
warn "Free tier search returned $FREE_CODE (check SearXNG connectivity)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- summary ----------
|
||||||
|
step "Bootstrap complete"
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
${C_BOLD}Services${C_RESET}
|
||||||
|
────────────────────────────────────────────────────────
|
||||||
|
Dashboard UI http://localhost:51300
|
||||||
|
Web API http://localhost:51100
|
||||||
|
Dashboard DB localhost:15432 (internal only in prod)
|
||||||
|
|
||||||
|
${C_BOLD}Containers${C_RESET}
|
||||||
|
────────────────────────────────────────────────────────
|
||||||
|
$(docker ps --format " {{.Names}} ({{.Status}})" | grep -E "didiAI-(dashboard|web-api)" | sort)
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
if [[ -n "$ADMIN_TOKEN" ]]; then
|
||||||
|
cat <<EOF
|
||||||
|
${C_BOLD}Admin Bearer token${C_RESET} (save this!)
|
||||||
|
────────────────────────────────────────────────────────
|
||||||
|
${C_GREEN}$ADMIN_TOKEN${C_RESET}
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
curl -H "Authorization: Bearer $ADMIN_TOKEN" \\
|
||||||
|
-X PUT http://localhost:51300/api/config/web.premium.strategy \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{"value":"parallel"}'
|
||||||
|
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat <<EOF
|
||||||
|
${C_BOLD}Next steps${C_RESET}
|
||||||
|
────────────────────────────────────────────────────────
|
||||||
|
1. Open http://localhost:51300 in your browser
|
||||||
|
2. Verify providers on /providers page
|
||||||
|
3. Adjust runtime config on /config page
|
||||||
|
4. Read DEPLOYMENT.md for details + troubleshooting
|
||||||
|
EOF
|
||||||
40
ai_platform/modules/audio/.env.example
Normal file
40
ai_platform/modules/audio/.env.example
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# audio configuration
|
||||||
|
# Copy to deploy/.env and fill ALL required values.
|
||||||
|
# Service must FAIL to start if required vars are missing.
|
||||||
|
# =============================================================================
|
||||||
|
# REQUIRED (no defaults)
|
||||||
|
# =============================================================================
|
||||||
|
# Whisper model name (tiny, base, small, medium, large-v2, large-v3, large-v3-turbo)
|
||||||
|
AUDIO_MODEL=large-v3-turbo
|
||||||
|
|
||||||
|
# Device: cuda or cpu
|
||||||
|
AUDIO_DEVICE=cuda
|
||||||
|
|
||||||
|
# Model cache directory (shared with other modules)
|
||||||
|
AUDIO_CACHE_DIR=/cai2_ds_storage/hf_cache
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Optional Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# Compute type: float16, int8, int8_float16 (int8 recommended for GPU)
|
||||||
|
AUDIO_COMPUTE_TYPE=int8
|
||||||
|
|
||||||
|
# Transcription settings
|
||||||
|
# AUDIO_BEAM_SIZE=5
|
||||||
|
# AUDIO_BEST_OF=5
|
||||||
|
# AUDIO_TEMPERATURE=0.0
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
# AUDIO_HOST=0.0.0.0
|
||||||
|
# AUDIO_PORT=8200
|
||||||
|
# AUDIO_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Upload limits (in MB)
|
||||||
|
# AUDIO_MAX_FILE_SIZE_MB=500
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Optional Nginx Timeouts (for api-nginx profile)
|
||||||
|
# =============================================================================
|
||||||
|
# NGINX_CONNECT_TIMEOUT=60s
|
||||||
|
# NGINX_SEND_TIMEOUT=300s
|
||||||
|
# NGINX_READ_TIMEOUT=600s
|
||||||
453
ai_platform/modules/audio/API.md
Normal file
453
ai_platform/modules/audio/API.md
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
# Audio Transcription API Documentation
|
||||||
|
|
||||||
|
OpenAI-compatible speech-to-text transcription API using faster-whisper.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
{BASE_URL}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Local development:** `http://localhost:8200`
|
||||||
|
- **Docker (internal):** `http://audio-api:8200`
|
||||||
|
- **Direct:** `http://localhost:54300`
|
||||||
|
- **Production:** Use your configured hostname
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
No authentication required by default. Can be added via nginx or API gateway if needed.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
Check API health status.
|
||||||
|
|
||||||
|
**Endpoint:** `GET /health`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8200/health
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List Models
|
||||||
|
|
||||||
|
List available Whisper models (OpenAI-compatible).
|
||||||
|
|
||||||
|
**Endpoint:** `GET /v1/models`
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": "large-v3-turbo",
|
||||||
|
"object": "model",
|
||||||
|
"created": 1700000000,
|
||||||
|
"owned_by": "openai"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8200/v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Create Transcription
|
||||||
|
|
||||||
|
Transcribe audio file to text (OpenAI-compatible endpoint).
|
||||||
|
|
||||||
|
**Endpoint:** `POST /v1/audio/transcriptions`
|
||||||
|
|
||||||
|
**Content-Type:** `multipart/form-data`
|
||||||
|
|
||||||
|
**Request Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Default | Description |
|
||||||
|
|-----------|------|----------|---------|-------------|
|
||||||
|
| `file` | file | Yes | - | Audio file to transcribe (MP3, WAV, M4A, etc.) |
|
||||||
|
| `model` | string | No | `large-v3-turbo` | Model to use (currently ignored, uses configured model) |
|
||||||
|
| `language` | string | No | `null` | Language code (ISO-639-1). Auto-detected if not specified. |
|
||||||
|
| `prompt` | string | No | `null` | Optional text to guide the model's style |
|
||||||
|
| `response_format` | string | No | `json` | Format: `json`, `text`, or `verbose_json` |
|
||||||
|
| `temperature` | float | No | `0.0` | Sampling temperature (0.0-1.0). Use 0.0 for deterministic output. |
|
||||||
|
|
||||||
|
**Supported Languages (ISO-639-1 codes):**
|
||||||
|
`en`, `es`, `fr`, `de`, `it`, `pt`, `nl`, `pl`, `tr`, `ru`, `ja`, `ko`, `zh`, `ar`, `hi`, and 90+ more languages.
|
||||||
|
|
||||||
|
**Response Formats:**
|
||||||
|
|
||||||
|
#### 1. JSON (default)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "Full transcription text",
|
||||||
|
"language": "en",
|
||||||
|
"duration": 45.5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. Text
|
||||||
|
```
|
||||||
|
Full transcription text
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. Verbose JSON
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "Full transcription text",
|
||||||
|
"language": "en",
|
||||||
|
"duration": 45.5,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"seek": 0,
|
||||||
|
"start": 0.0,
|
||||||
|
"end": 3.5,
|
||||||
|
"text": "Hello, world!",
|
||||||
|
"tokens": [15496, 11, 1002, 0],
|
||||||
|
"temperature": 0.0,
|
||||||
|
"avg_logprob": -0.25,
|
||||||
|
"compression_ratio": 1.5,
|
||||||
|
"no_speech_prob": 0.01
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
|
||||||
|
**Basic transcription (JSON):**
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||||
|
-F "file=@audio.mp3" \
|
||||||
|
-F "response_format=json"
|
||||||
|
```
|
||||||
|
|
||||||
|
**With language specification:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||||
|
-F "file=@audio.mp3" \
|
||||||
|
-F "language=en" \
|
||||||
|
-F "response_format=json"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Text format:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||||
|
-F "file=@audio.mp3" \
|
||||||
|
-F "response_format=text"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verbose JSON with segments:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||||
|
-F "file=@audio.mp3" \
|
||||||
|
-F "response_format=verbose_json"
|
||||||
|
```
|
||||||
|
|
||||||
|
**With initial prompt (to guide style):**
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||||
|
-F "file=@audio.mp3" \
|
||||||
|
-F "prompt=This is a technical discussion about machine learning." \
|
||||||
|
-F "response_format=json"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Python example:**
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
url = "http://localhost:8200/v1/audio/transcriptions"
|
||||||
|
|
||||||
|
with open("audio.mp3", "rb") as f:
|
||||||
|
files = {"file": f}
|
||||||
|
data = {
|
||||||
|
"model": "large-v3-turbo",
|
||||||
|
"language": "en",
|
||||||
|
"response_format": "verbose_json"
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(url, files=files, data=data)
|
||||||
|
result = response.json()
|
||||||
|
|
||||||
|
print(f"Transcription: {result['text']}")
|
||||||
|
print(f"Duration: {result['duration']}s")
|
||||||
|
print(f"Segments: {len(result['segments'])}")
|
||||||
|
```
|
||||||
|
|
||||||
|
**JavaScript example:**
|
||||||
|
```javascript
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', audioFile);
|
||||||
|
formData.append('model', 'large-v3-turbo');
|
||||||
|
formData.append('response_format', 'json');
|
||||||
|
|
||||||
|
const response = await fetch('http://localhost:8200/v1/audio/transcriptions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Transcription:', result.text);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
### Standard Error Format
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Error message"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common HTTP Status Codes
|
||||||
|
|
||||||
|
| Code | Meaning | Description |
|
||||||
|
|------|---------|-------------|
|
||||||
|
| 200 | OK | Request successful |
|
||||||
|
| 400 | Bad Request | Invalid request parameters |
|
||||||
|
| 413 | Payload Too Large | File exceeds max size limit (default 500MB) |
|
||||||
|
| 422 | Unprocessable Entity | Invalid file format or corrupted audio |
|
||||||
|
| 500 | Internal Server Error | Transcription failed |
|
||||||
|
| 503 | Service Unavailable | Model not loaded or GPU unavailable |
|
||||||
|
|
||||||
|
**Example error response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "File too large: 550.0MB (max: 500MB)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Rate Limiting
|
||||||
|
|
||||||
|
No built-in rate limiting. Can be added via nginx or API gateway.
|
||||||
|
|
||||||
|
**Recommended nginx configuration:**
|
||||||
|
```nginx
|
||||||
|
limit_req_zone $binary_remote_addr zone=audio_limit:10m rate=10r/m;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
limit_req zone=audio_limit burst=5;
|
||||||
|
proxy_pass http://audio-api:8200;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Size Limits
|
||||||
|
|
||||||
|
- **Default:** 500MB per file
|
||||||
|
- **Configurable via:** `AUDIO_MAX_FILE_SIZE_MB` environment variable
|
||||||
|
- **Recommended:** Keep files under 100MB for best performance
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Supported Audio Formats
|
||||||
|
|
||||||
|
All formats supported by FFmpeg, including:
|
||||||
|
|
||||||
|
**Audio files:**
|
||||||
|
- MP3, WAV, FLAC, OGG, M4A, AAC, WMA, OPUS
|
||||||
|
|
||||||
|
**Video files (audio track extraction):**
|
||||||
|
- MP4, AVI, MKV, MOV, WEBM, FLV
|
||||||
|
|
||||||
|
**Professional formats:**
|
||||||
|
- PCM, AIFF, AU, AMR
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Processing Time
|
||||||
|
|
||||||
|
Approximate transcription times for large-v3-turbo with int8 on H200 GPU:
|
||||||
|
|
||||||
|
| Audio Length | Processing Time | Real-Time Factor |
|
||||||
|
|--------------|-----------------|------------------|
|
||||||
|
| 1 minute | ~3-5 seconds | 0.05-0.08x |
|
||||||
|
| 10 minutes | ~30-50 seconds | 0.05-0.08x |
|
||||||
|
| 1 hour | ~3-5 minutes | 0.05-0.08x |
|
||||||
|
|
||||||
|
**Real-Time Factor (RTF):** Processing time / Audio duration. Lower is better.
|
||||||
|
- RTF < 0.1x = Excellent (8x faster than real-time)
|
||||||
|
- RTF < 0.5x = Good (2x faster than real-time)
|
||||||
|
- RTF = 1.0x = Real-time
|
||||||
|
|
||||||
|
### Optimization Tips
|
||||||
|
|
||||||
|
1. **Use language parameter** when known (skip auto-detection)
|
||||||
|
2. **Use appropriate model size** based on accuracy needs
|
||||||
|
3. **Process shorter segments** for long recordings (split at silence)
|
||||||
|
4. **Monitor GPU utilization** with `nvidia-smi`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comparison with OpenAI API
|
||||||
|
|
||||||
|
This API is **fully compatible** with OpenAI's transcription endpoint:
|
||||||
|
|
||||||
|
| Feature | OpenAI | This API |
|
||||||
|
|---------|--------|----------|
|
||||||
|
| Endpoint | `/v1/audio/transcriptions` | `/v1/audio/transcriptions` ✅ |
|
||||||
|
| Request format | `multipart/form-data` | `multipart/form-data` ✅ |
|
||||||
|
| Response formats | `json`, `text`, `verbose_json` | `json`, `text`, `verbose_json` ✅ |
|
||||||
|
| Language detection | Auto | Auto ✅ |
|
||||||
|
| Max file size | 25MB | 500MB (configurable) ✅ |
|
||||||
|
| Cost | $0.006/minute | Free (self-hosted) ✅ |
|
||||||
|
| Latency | Variable | ~0.05-0.08x RTF ✅ |
|
||||||
|
|
||||||
|
**Drop-in replacement:** Change only the base URL to switch from OpenAI to this API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SDK Examples
|
||||||
|
|
||||||
|
### OpenAI Python SDK
|
||||||
|
|
||||||
|
```python
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
# Point to local API
|
||||||
|
client = OpenAI(
|
||||||
|
api_key="not-needed", # No auth required
|
||||||
|
base_url="http://localhost:8200/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
with open("audio.mp3", "rb") as f:
|
||||||
|
transcript = client.audio.transcriptions.create(
|
||||||
|
model="large-v3-turbo",
|
||||||
|
file=f,
|
||||||
|
response_format="verbose_json"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(transcript.text)
|
||||||
|
```
|
||||||
|
|
||||||
|
### OpenAI Node.js SDK
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import OpenAI from 'openai';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
|
const openai = new OpenAI({
|
||||||
|
apiKey: 'not-needed',
|
||||||
|
baseURL: 'http://localhost:8200/v1'
|
||||||
|
});
|
||||||
|
|
||||||
|
const transcription = await openai.audio.transcriptions.create({
|
||||||
|
file: fs.createReadStream('audio.mp3'),
|
||||||
|
model: 'large-v3-turbo',
|
||||||
|
response_format: 'verbose_json'
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(transcription.text);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Advanced Features
|
||||||
|
|
||||||
|
### Voice Activity Detection (VAD)
|
||||||
|
|
||||||
|
Enabled by default. Automatically skips silence for:
|
||||||
|
- Faster processing
|
||||||
|
- Better accuracy
|
||||||
|
- Smaller output
|
||||||
|
|
||||||
|
### Beam Search
|
||||||
|
|
||||||
|
Configurable via `AUDIO_BEAM_SIZE` (default: 5). Higher values = better accuracy but slower.
|
||||||
|
|
||||||
|
### Temperature Sampling
|
||||||
|
|
||||||
|
- `0.0` (default): Deterministic output
|
||||||
|
- `0.0-1.0`: More creative/diverse outputs (less reliable)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check if API is ready
|
||||||
|
curl http://localhost:8200/health
|
||||||
|
|
||||||
|
# Expected response
|
||||||
|
{"status": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# View real-time logs
|
||||||
|
cd deploy/
|
||||||
|
docker compose logs -f audio-api
|
||||||
|
|
||||||
|
# Check for errors
|
||||||
|
docker compose logs audio-api | grep ERROR
|
||||||
|
```
|
||||||
|
|
||||||
|
### GPU Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Monitor GPU while transcribing
|
||||||
|
watch -n 1 nvidia-smi
|
||||||
|
|
||||||
|
# Check VRAM usage
|
||||||
|
nvidia-smi --query-gpu=memory.used,memory.total --format=csv
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Error: File too large
|
||||||
|
|
||||||
|
**Solution:** Increase max file size or split audio into smaller chunks:
|
||||||
|
```bash
|
||||||
|
# Increase limit
|
||||||
|
AUDIO_MAX_FILE_SIZE_MB=1000
|
||||||
|
|
||||||
|
# Or split with ffmpeg
|
||||||
|
ffmpeg -i long_audio.mp3 -f segment -segment_time 600 -c copy chunk_%03d.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error: Transcription failed
|
||||||
|
|
||||||
|
**Possible causes:**
|
||||||
|
1. Corrupted audio file - verify with media player
|
||||||
|
2. Unsupported format - convert to MP3/WAV
|
||||||
|
3. GPU out of memory - use smaller model or CPU mode
|
||||||
|
4. Audio is pure noise/music - Whisper is designed for speech
|
||||||
|
|
||||||
|
### Slow performance
|
||||||
|
|
||||||
|
**Check:**
|
||||||
|
1. GPU is being used: `docker exec audio-api nvidia-smi`
|
||||||
|
2. Model is correct: Check logs for model loading messages
|
||||||
|
3. Compute type is int8: Faster than float16
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
For more information, see [README.md](README.md) and the [faster-whisper documentation](https://github.com/SYSTRAN/faster-whisper).
|
||||||
137
ai_platform/modules/audio/INDEX.md
Normal file
137
ai_platform/modules/audio/INDEX.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# Audio Transcription Service (M17-Whisper)
|
||||||
|
|
||||||
|
Audio transcription service for DIDI media analysis. Whisper-based (M17-Whisper, faster-whisper backend), used by agent-v3 media-preprocess worker for video/audio session pipelines (techniques, ai-tampered, claims). OpenAI-compatible API — drop-in for `client.audio.transcriptions.create()`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Python 3.10+ / FastAPI / Uvicorn
|
||||||
|
- Backend: `faster-whisper` >= 1.0.0 (CTranslate2 optimized inference)
|
||||||
|
- Default model: `large-v3-turbo` (809M params, ~6GB VRAM int8)
|
||||||
|
- GPU: CUDA (shared GPU 0 with Qwen3.5-35B-A3B)
|
||||||
|
- URL (Dev): `http://10.11.10.17:54300/v1/audio/transcriptions`
|
||||||
|
- Container: `didiAI-audio-api` (GPU host)
|
||||||
|
- Auth: none on the service itself; agent-v3 uses bearer token via `M17_WHISPER_TOKEN` (enforced by gateway/nginx if configured)
|
||||||
|
|
||||||
|
## Ce face
|
||||||
|
Speech-to-text transcription pe fişiere audio sau URL-uri. agent-v3 trimite fie audio buffer (multipart upload), fie URL public (din MinIO), primeşte text + metadata (lang detect, duration, optional segments). Folosit în:
|
||||||
|
- **Video analysis pipeline** — agent-v3 extrage track-ul audio cu ffmpeg, trimite la M17-Whisper, foloseşte transcript pentru `claims` + `techniques` + `ai-tampered`
|
||||||
|
- **Audio-only sessions** — direct upload, transcripted, apoi pipeline normal de analiză text
|
||||||
|
- **Cascade fallback** — agent-v3 `transcription.ts` are cascadă: M17-Whisper (local GPU) → Groq Whisper → OpenAI Whisper. Dacă local pică sau întoarce string gol, trece la următorul provider.
|
||||||
|
|
||||||
|
## API endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| `GET` | `/health` | Health probe (returns `{"status":"ok"}`) |
|
||||||
|
| `GET` | `/v1/models` | List models, OpenAI-compatible |
|
||||||
|
| `POST` | `/v1/audio/transcriptions` | Transcribe audio (OpenAI-compatible) |
|
||||||
|
| `GET` | `/v1/info` | Service catalog metadata (used by didi catalog-api) |
|
||||||
|
|
||||||
|
### `POST /v1/audio/transcriptions`
|
||||||
|
Content-Type: `multipart/form-data`
|
||||||
|
|
||||||
|
| Param | Type | Default | Notes |
|
||||||
|
|-------|------|---------|-------|
|
||||||
|
| `file` | file | — | Audio file (MP3/WAV/M4A/FLAC/OGG/MP4 audio track…). XOR with `url`. |
|
||||||
|
| `url` | string | — | URL to download audio from (DIDI extension over OpenAI). XOR with `file`. |
|
||||||
|
| `model` | string | configured | Ignored — server uses `AUDIO_MODEL` env. |
|
||||||
|
| `language` | string | auto | ISO-639-1 code (`en`, `ro`, `es`, …). Auto-detect if omitted. |
|
||||||
|
| `prompt` | string | none | Optional initial prompt to bias style/vocab. |
|
||||||
|
| `response_format` | string | `json` | `json` \| `text` \| `verbose_json` (segments). |
|
||||||
|
| `temperature` | float | `0.0` | Sampling temperature 0.0–1.0. |
|
||||||
|
|
||||||
|
Response (json): `{ text, language, duration }` — verbose_json adds `segments[]` with `start/end/text/tokens/avg_logprob/no_speech_prob`.
|
||||||
|
|
||||||
|
Errors: `400` invalid params / download failed, `413` payload too large (default 500MB), `422` corrupted audio, `500` transcription failed, `503` model not loaded.
|
||||||
|
|
||||||
|
## Backends / Models
|
||||||
|
- Production: `large-v3-turbo` (8x faster than `large-v3`, similar quality, ~6GB VRAM int8)
|
||||||
|
- Available: `tiny`, `base`, `small`, `medium`, `large-v3`, `large-v3-turbo`
|
||||||
|
- VAD (Voice Activity Detection) enabled by default — skip silence
|
||||||
|
- Quantization: `int8` (recommended), `float16`, `int8_float16`
|
||||||
|
|
||||||
|
## How didi-backend uses it
|
||||||
|
- agent-v3 cascade: `backend/services/orchestration-layer/agent-v3/src/shared/media/transcription.ts`
|
||||||
|
- Order: **M17-Whisper (local GPU)** → Groq Whisper → OpenAI Whisper
|
||||||
|
- Empty-string from local triggers retry on next provider (Faza 1 din SAFETY_NETS_PLAN)
|
||||||
|
- Configured în agent-v3 docker-compose env:
|
||||||
|
- `M17_WHISPER_URL=http://10.11.10.17:54300`
|
||||||
|
- `M17_WHISPER_TOKEN=<gateway-token>` (optional, dacă există proxy auth)
|
||||||
|
- Apelat din **media-preprocess worker** la sesiuni cu `media_type` în `{audio, video}` — transcript devine input pentru `claims-routes.ts`, `routes.ts` (techniques), `ai-tampered-routes.ts`.
|
||||||
|
- Video flow: ffmpeg extract → upload temp / pass buffer → POST `/v1/audio/transcriptions` → text → pipeline analysis.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Env vars (prefix `AUDIO_`, set in `deploy/.env`):
|
||||||
|
|
||||||
|
**Required**
|
||||||
|
- `AUDIO_MODEL` — Whisper model (default `large-v3-turbo`)
|
||||||
|
- `AUDIO_DEVICE` — `cuda` | `cpu`
|
||||||
|
- `AUDIO_CACHE_DIR` — HuggingFace model cache path (`/cai2_ds_storage/hf_cache` typical)
|
||||||
|
- `AUDIO_EXTERNAL_URL` — used in OpenAPI servers spec
|
||||||
|
|
||||||
|
**Optional**
|
||||||
|
- `AUDIO_COMPUTE_TYPE` — `int8` (default), `float16`, `int8_float16`
|
||||||
|
- `AUDIO_BEAM_SIZE` — beam search width (default 5)
|
||||||
|
- `AUDIO_BEST_OF` — sampling candidates (default 5)
|
||||||
|
- `AUDIO_TEMPERATURE` — default 0.0
|
||||||
|
- `AUDIO_HOST` / `AUDIO_PORT` — default `0.0.0.0:54300`
|
||||||
|
- `AUDIO_LOG_LEVEL` — default `INFO`
|
||||||
|
- `AUDIO_MAX_FILE_SIZE_MB` — upload cap (default 500)
|
||||||
|
- `CUDA_VISIBLE_DEVICES=0` — pinned to GPU 0
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Compose dir: `/home/admin365/didi_mono/ai_platform/modules/audio/deploy/`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd modules/audio/deploy
|
||||||
|
cp ../.env.example .env # edit values
|
||||||
|
./deploy.sh --profile api --detach # API only
|
||||||
|
./deploy.sh --profile api-nginx --detach # with nginx reverse proxy
|
||||||
|
./deploy.sh --profile api --logs # tail logs
|
||||||
|
docker compose restart audio-api # quick restart
|
||||||
|
```
|
||||||
|
|
||||||
|
- Container name: `didiAI-audio-api`
|
||||||
|
- Image: `didiai-audio-api`
|
||||||
|
- Network: `didi-network` (external, shared with other AI modules)
|
||||||
|
- GPU reservation: NVIDIA driver, device `0`
|
||||||
|
- Healthcheck: HTTP `GET /health` every 30s, 60s start period (model load)
|
||||||
|
- Model cache mount: `${AUDIO_CACHE_DIR}:/root/.cache/huggingface`
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
- Real-Time Factor on H200 GPU + int8 + `large-v3-turbo`: **~0.05–0.08x** (10s audio = 0.5–1s transcription, 1h audio = 3–5min)
|
||||||
|
- VRAM: ~6GB for large-v3-turbo int8, ~8GB float16
|
||||||
|
- Throughput: sequential — process files one at a time per GPU
|
||||||
|
- File size cap: 500MB default (configurable)
|
||||||
|
- Recommended: pre-split audio > 1h cu ffmpeg segments
|
||||||
|
|
||||||
|
## Architecture (source layout)
|
||||||
|
```
|
||||||
|
modules/audio/
|
||||||
|
├── deploy/
|
||||||
|
│ ├── deploy.sh # CLI wrapper
|
||||||
|
│ ├── docker-compose.yml # didiAI-audio-api service
|
||||||
|
│ ├── Dockerfile # CUDA + faster-whisper image
|
||||||
|
│ └── .env # runtime config
|
||||||
|
├── src/audio/
|
||||||
|
│ ├── app.py # FastAPI routes + /v1/info catalog
|
||||||
|
│ ├── transcriber.py # faster-whisper wrapper (singleton)
|
||||||
|
│ ├── schemas.py # TranscriptionResponse / Segment
|
||||||
|
│ └── settings.py # pydantic-settings (AUDIO_* env)
|
||||||
|
├── tests/
|
||||||
|
├── pyproject.toml # fastapi, faster-whisper, httpx
|
||||||
|
├── README.md # full operator guide
|
||||||
|
├── API.md # full HTTP API reference
|
||||||
|
└── INDEX.md # this file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Related
|
||||||
|
- agent-v3 transcription cascade — `backend/services/orchestration-layer/agent-v3/src/shared/media/transcription.ts`
|
||||||
|
- agent-v3 media routes — `routes.ts` / `ai-tampered-routes.ts` / `claims-routes.ts`
|
||||||
|
- media-preprocess worker (in agent-v3) — invokes this service
|
||||||
|
- Test files in MinIO (used during refactor verification):
|
||||||
|
- Audio: `https://didi365.eu/api/v3/media/file/uploads/test-user/1771883851173-audio_with_voice.mp3`
|
||||||
|
- Video: `https://didi365.eu/api/v3/media/file/uploads/test-user/1771883852186-voice_video.mp4`
|
||||||
|
- Faza 1 safety net (transcription retry on empty) — `agent-v3/SAFETY_NETS_PLAN.md`
|
||||||
|
- Upstream refs: [faster-whisper](https://github.com/SYSTRAN/faster-whisper), [OpenAI Whisper](https://github.com/openai/whisper)
|
||||||
289
ai_platform/modules/audio/README.md
Normal file
289
ai_platform/modules/audio/README.md
Normal file
|
|
@ -0,0 +1,289 @@
|
||||||
|
# Audio Transcription
|
||||||
|
|
||||||
|
Speech-to-text transcription service using faster-whisper for optimized inference.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **High-Performance Transcription**: Uses faster-whisper (4x faster than original Whisper)
|
||||||
|
- **Multiple Model Sizes**: Support for tiny, base, small, medium, large-v2, large-v3, and large-v3-turbo
|
||||||
|
- **GPU Acceleration**: Optimized CUDA inference with int8 quantization
|
||||||
|
- **OpenAI-Compatible API**: Drop-in replacement for OpenAI's transcription endpoint
|
||||||
|
- **Multiple Output Formats**: JSON, text, and verbose JSON with segments
|
||||||
|
- **Language Detection**: Automatic language detection for 99+ languages
|
||||||
|
- **VAD Filtering**: Voice Activity Detection for improved accuracy
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
**Required (all modules):**
|
||||||
|
- All global prerequisites (see main [README.md](../../README.md))
|
||||||
|
- NVIDIA GPU with CUDA support (recommended for production)
|
||||||
|
- ~6GB VRAM for large-v3-turbo with int8 quantization
|
||||||
|
|
||||||
|
**Optional:**
|
||||||
|
- CPU-only mode available (slower but no GPU required)
|
||||||
|
|
||||||
|
> **Note:** The audio module runs on GPU 0 (shared with Qwen3.5-35B-A3B).
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd modules/audio
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Install with dev dependencies
|
||||||
|
uv sync --extra dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### As an API Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy/
|
||||||
|
|
||||||
|
# Create deployment env file
|
||||||
|
cp ../.env.example .env
|
||||||
|
# Edit deploy/.env with your configuration
|
||||||
|
|
||||||
|
# Start the server
|
||||||
|
./deploy.sh --profile api --detach
|
||||||
|
|
||||||
|
# Or with nginx reverse proxy
|
||||||
|
./deploy.sh --profile api-nginx --detach
|
||||||
|
```
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/health` | GET | Health check |
|
||||||
|
| `/v1/models` | GET | List available models |
|
||||||
|
| `/v1/audio/transcriptions` | POST | Transcribe audio (OpenAI-compatible) |
|
||||||
|
|
||||||
|
### Example API Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Health check
|
||||||
|
curl http://localhost:8200/health
|
||||||
|
|
||||||
|
# Transcribe audio file
|
||||||
|
AUDIO="/path/to/audio.mp3"
|
||||||
|
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||||
|
-F "file=@${AUDIO}" \
|
||||||
|
-F "model=large-v3-turbo" \
|
||||||
|
-F "response_format=json"
|
||||||
|
|
||||||
|
# With language specification and verbose output
|
||||||
|
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||||
|
-F "file=@${AUDIO}" \
|
||||||
|
-F "language=en" \
|
||||||
|
-F "response_format=verbose_json"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Response
|
||||||
|
|
||||||
|
**JSON format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "This is the full transcription of your audio file.",
|
||||||
|
"language": "en",
|
||||||
|
"duration": 45.5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Verbose JSON format:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"text": "This is the full transcription.",
|
||||||
|
"language": "en",
|
||||||
|
"duration": 45.5,
|
||||||
|
"segments": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"start": 0.0,
|
||||||
|
"end": 3.5,
|
||||||
|
"text": "This is the full transcription.",
|
||||||
|
"tokens": [123, 456, 789],
|
||||||
|
"temperature": 0.0,
|
||||||
|
"avg_logprob": -0.25,
|
||||||
|
"compression_ratio": 1.5,
|
||||||
|
"no_speech_prob": 0.01
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Required Environment Variables
|
||||||
|
|
||||||
|
Configured via environment variables (prefix: `AUDIO_`). These are typically set in `deploy/.env`:
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `AUDIO_MODEL` | Whisper model name (e.g., `large-v3-turbo`) |
|
||||||
|
| `AUDIO_DEVICE` | Device: `cuda` or `cpu` |
|
||||||
|
| `AUDIO_CACHE_DIR` | Model cache directory |
|
||||||
|
|
||||||
|
### Optional Environment Variables
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `AUDIO_COMPUTE_TYPE` | `int8` | Compute type: `float16`, `int8`, `int8_float16` |
|
||||||
|
| `AUDIO_BEAM_SIZE` | `5` | Beam size for decoding (1-10) |
|
||||||
|
| `AUDIO_BEST_OF` | `5` | Number of candidates when sampling |
|
||||||
|
| `AUDIO_TEMPERATURE` | `0.0` | Sampling temperature (0.0-1.0) |
|
||||||
|
| `AUDIO_LOG_LEVEL` | `INFO` | Log level |
|
||||||
|
| `AUDIO_MAX_FILE_SIZE_MB` | `500` | Max upload file size in MB |
|
||||||
|
|
||||||
|
### Available Models
|
||||||
|
|
||||||
|
| Model | Parameters | VRAM (int8) | Speed | Quality |
|
||||||
|
|-------|-----------|-------------|-------|---------|
|
||||||
|
| `tiny` | 39M | ~1GB | 10x | Basic |
|
||||||
|
| `base` | 74M | ~1GB | 7x | Good |
|
||||||
|
| `small` | 244M | ~2GB | 4x | Better |
|
||||||
|
| `medium` | 769M | ~3-4GB | 2x | Very Good |
|
||||||
|
| `large-v3` | 1550M | ~6-8GB | 1x | Excellent |
|
||||||
|
| `large-v3-turbo` | 809M | ~6GB | 8x | Excellent |
|
||||||
|
|
||||||
|
**Recommendation:** Use `large-v3-turbo` for best balance of speed and accuracy.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy/
|
||||||
|
|
||||||
|
# Create deployment env file (REQUIRED)
|
||||||
|
cp ../.env.example .env
|
||||||
|
# Edit deploy/.env with your settings
|
||||||
|
|
||||||
|
# API only
|
||||||
|
./deploy.sh --profile api --detach
|
||||||
|
|
||||||
|
# API with nginx reverse proxy
|
||||||
|
./deploy.sh --profile api-nginx --detach
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
./deploy.sh --profile api --logs
|
||||||
|
|
||||||
|
# Stop services
|
||||||
|
./deploy.sh --profile api --down
|
||||||
|
```
|
||||||
|
|
||||||
|
### Common Docker Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy/
|
||||||
|
|
||||||
|
# Restart the API service
|
||||||
|
docker compose restart audio-api
|
||||||
|
|
||||||
|
# Rebuild after code changes
|
||||||
|
docker compose build audio-api
|
||||||
|
docker compose up -d audio-api
|
||||||
|
|
||||||
|
# Check logs
|
||||||
|
docker compose logs -f audio-api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Port Allocation
|
||||||
|
|
||||||
|
| Port | Service |
|
||||||
|
|------|---------|
|
||||||
|
| `8200` | Audio API |
|
||||||
|
| `54300` | Audio API (Dev + AI + Audio) |
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dev dependencies
|
||||||
|
uv sync --extra dev
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
uv run pytest
|
||||||
|
|
||||||
|
# Run tests with coverage
|
||||||
|
uv run pytest --cov=src/audio --cov-report=term-missing
|
||||||
|
|
||||||
|
# Lint and format
|
||||||
|
uv run ruff check .
|
||||||
|
uv run ruff format .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
modules/audio/
|
||||||
|
├── deploy/
|
||||||
|
│ ├── deploy.sh # Deployment script
|
||||||
|
│ ├── docker-compose.yml # Docker services
|
||||||
|
│ ├── Dockerfile # Container image
|
||||||
|
│ └── nginx.conf # Nginx reverse proxy config (optional)
|
||||||
|
├── src/audio/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── app.py # FastAPI application
|
||||||
|
│ ├── transcriber.py # faster-whisper wrapper
|
||||||
|
│ ├── schemas.py # Response schemas
|
||||||
|
│ └── settings.py # Configuration
|
||||||
|
├── tests/
|
||||||
|
├── .env.example # Environment template
|
||||||
|
├── API.md # API documentation
|
||||||
|
├── pyproject.toml # Dependencies
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Audio Formats
|
||||||
|
|
||||||
|
faster-whisper (via FFmpeg) supports:
|
||||||
|
- MP3, WAV, FLAC, OGG, M4A, AAC, WMA
|
||||||
|
- MP4, AVI, MKV (audio track extraction)
|
||||||
|
- And many more formats supported by FFmpeg
|
||||||
|
|
||||||
|
## Performance Tips
|
||||||
|
|
||||||
|
1. **Use int8 quantization** for GPU inference (40% memory savings, minimal accuracy loss)
|
||||||
|
2. **Use large-v3-turbo** for best speed/accuracy tradeoff (8x faster than large-v3)
|
||||||
|
3. **Enable VAD filtering** (enabled by default) to skip silence
|
||||||
|
4. **Batch processing**: Process multiple files sequentially for better GPU utilization
|
||||||
|
5. **Language specification**: Specify language code when known for faster processing
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Issue: Out of Memory
|
||||||
|
|
||||||
|
**Solution:** Use a smaller model or reduce compute type:
|
||||||
|
```bash
|
||||||
|
# Use medium model instead
|
||||||
|
AUDIO_MODEL=medium
|
||||||
|
|
||||||
|
# Or use float16 instead of int8 (uses more VRAM but may work better)
|
||||||
|
AUDIO_COMPUTE_TYPE=float16
|
||||||
|
```
|
||||||
|
|
||||||
|
### Issue: Slow Transcription
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Ensure GPU is being used (`AUDIO_DEVICE=cuda`)
|
||||||
|
- Use int8 compute type for faster inference
|
||||||
|
- Use large-v3-turbo instead of large-v3
|
||||||
|
- Check GPU utilization with `nvidia-smi`
|
||||||
|
|
||||||
|
### Issue: Model Download Fails
|
||||||
|
|
||||||
|
**Solution:** Check network connectivity and cache directory permissions:
|
||||||
|
```bash
|
||||||
|
# Verify cache directory exists and is writable
|
||||||
|
ls -la /cai2_ds_storage/hf_cache
|
||||||
|
|
||||||
|
# Or change to local directory
|
||||||
|
AUDIO_CACHE_DIR=/home/user/.cache/huggingface
|
||||||
|
```
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [faster-whisper GitHub](https://github.com/SYSTRAN/faster-whisper)
|
||||||
|
- [OpenAI Whisper](https://github.com/openai/whisper)
|
||||||
|
- [Whisper Model Card](https://github.com/openai/whisper/blob/main/model-card.md)
|
||||||
40
ai_platform/modules/audio/deploy/Dockerfile
Normal file
40
ai_platform/modules/audio/deploy/Dockerfile
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Audio Transcription Service - Dockerfile
|
||||||
|
# Uses faster-whisper for optimized speech-to-text transcription
|
||||||
|
|
||||||
|
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
|
||||||
|
|
||||||
|
# Prevent interactive prompts during build
|
||||||
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
|
ENV TZ=Europe/Bucharest
|
||||||
|
|
||||||
|
# Install Python 3.10 (default in Ubuntu 22.04) and system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
python3-dev \
|
||||||
|
curl \
|
||||||
|
ffmpeg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& ln -sf /usr/bin/python3 /usr/bin/python
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy project files
|
||||||
|
COPY pyproject.toml /app/
|
||||||
|
COPY src/ /app/src/
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -e .
|
||||||
|
|
||||||
|
# Create cache directory
|
||||||
|
RUN mkdir -p /root/.cache/huggingface
|
||||||
|
|
||||||
|
# Default port
|
||||||
|
ENV AUDIO_PORT=54300
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 54300
|
||||||
|
|
||||||
|
# Run FastAPI with uvicorn
|
||||||
|
CMD python -m uvicorn audio.app:app --host 0.0.0.0 --port ${AUDIO_PORT}
|
||||||
116
ai_platform/modules/audio/deploy/deploy.sh
Normal file
116
ai_platform/modules/audio/deploy/deploy.sh
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Docker Compose Startup Script for Audio Transcription
|
||||||
|
#
|
||||||
|
# Usage: ./deploy/deploy.sh [OPTIONS]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --profile <api|api-nginx> Docker compose profile
|
||||||
|
# --detach Run in detached mode
|
||||||
|
# --down Stop and remove containers
|
||||||
|
# --logs Show logs
|
||||||
|
# --help Show this help message
|
||||||
|
#
|
||||||
|
# Required: Set environment variables in deploy/.env file or export them before running.
|
||||||
|
# See ../.env.example (module root) for the full list of variables.
|
||||||
|
#
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Load .env file ONLY from deploy/ directory
|
||||||
|
if [[ -f "$SCRIPT_DIR/.env" ]]; then
|
||||||
|
echo "Loading environment from: $SCRIPT_DIR/.env"
|
||||||
|
set -a
|
||||||
|
source "$SCRIPT_DIR/.env"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROFILE=""
|
||||||
|
DETACH=""
|
||||||
|
ACTION="up"
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
sed -n '2,18p' "$0" | sed 's/^# //' | sed 's/^#//'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check_required_var() {
|
||||||
|
local var_name="$1"
|
||||||
|
if [[ -z "${!var_name:-}" ]]; then
|
||||||
|
echo "ERROR: Required environment variable $var_name is not set"
|
||||||
|
echo "Set it in deploy/.env file or export it before running this script"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--profile)
|
||||||
|
PROFILE="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--detach|-d)
|
||||||
|
DETACH="-d"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--down)
|
||||||
|
ACTION="down"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--logs)
|
||||||
|
ACTION="logs"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
echo "Use --help for usage information"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Require profile when bringing up
|
||||||
|
if [[ -z "$PROFILE" && "$ACTION" == "up" ]]; then
|
||||||
|
echo "ERROR: --profile is required"
|
||||||
|
echo "Options: api, api-nginx"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fail-fast required vars
|
||||||
|
check_required_var "AUDIO_MODEL"
|
||||||
|
check_required_var "AUDIO_DEVICE"
|
||||||
|
check_required_var "AUDIO_CACHE_DIR"
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
case $ACTION in
|
||||||
|
up)
|
||||||
|
echo "Starting Audio Transcription API with profile: $PROFILE"
|
||||||
|
echo " Model: $AUDIO_MODEL"
|
||||||
|
echo " Device: $AUDIO_DEVICE"
|
||||||
|
echo " Compute type: ${AUDIO_COMPUTE_TYPE:-int8}"
|
||||||
|
echo " Cache dir: $AUDIO_CACHE_DIR"
|
||||||
|
echo ""
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
exec docker compose --profile "$PROFILE" up $DETACH
|
||||||
|
;;
|
||||||
|
down)
|
||||||
|
if [[ -z "$PROFILE" ]]; then
|
||||||
|
echo "ERROR: --profile is required with --down"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Stopping Audio Transcription containers..."
|
||||||
|
exec docker compose --profile "$PROFILE" down
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
if [[ -z "$PROFILE" ]]; then
|
||||||
|
echo "ERROR: --profile is required with --logs"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec docker compose --profile "$PROFILE" logs -f
|
||||||
|
;;
|
||||||
|
esac
|
||||||
95
ai_platform/modules/audio/deploy/docker-compose.yml
Normal file
95
ai_platform/modules/audio/deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Audio Module - Docker Compose Configuration
|
||||||
|
#
|
||||||
|
# Port Allocation (Dev AI Audio: 54300):
|
||||||
|
# 54300 - Audio API (Whisper STT service)
|
||||||
|
#
|
||||||
|
# Profiles:
|
||||||
|
# api - API server only
|
||||||
|
#
|
||||||
|
# Required environment variables (set in deploy/.env file):
|
||||||
|
# AUDIO_MODEL - Whisper model name (e.g., large-v3-turbo)
|
||||||
|
# AUDIO_DEVICE - Device: cuda or cpu
|
||||||
|
# AUDIO_COMPUTE_TYPE - Compute type: int8, float16, int8_float16
|
||||||
|
# AUDIO_CACHE_DIR - Model cache directory
|
||||||
|
#
|
||||||
|
# GPU Configuration:
|
||||||
|
# - Runs on GPU 0 (shared with Qwen3.5-35B-A3B)
|
||||||
|
# - Requires ~6GB VRAM for large-v3-turbo with int8
|
||||||
|
#
|
||||||
|
# Naming Convention: didiAI-{module}-{service}
|
||||||
|
#
|
||||||
|
# Network:
|
||||||
|
# Uses deploy_default network (shared with other modules)
|
||||||
|
|
||||||
|
networks:
|
||||||
|
deploy_default:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ==========================================================================
|
||||||
|
# Audio Transcription API Server
|
||||||
|
# ==========================================================================
|
||||||
|
audio-api:
|
||||||
|
container_name: didiAI-audio-api
|
||||||
|
image: didiai-audio-api
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile
|
||||||
|
|
||||||
|
ports:
|
||||||
|
- "54300:54300"
|
||||||
|
networks:
|
||||||
|
- deploy_default
|
||||||
|
|
||||||
|
environment:
|
||||||
|
# GPU configuration
|
||||||
|
- CUDA_VISIBLE_DEVICES=0
|
||||||
|
|
||||||
|
# External URL for OpenAPI spec (REQUIRED)
|
||||||
|
- AUDIO_EXTERNAL_URL=${AUDIO_EXTERNAL_URL}
|
||||||
|
|
||||||
|
# Whisper model configuration
|
||||||
|
- AUDIO_MODEL=${AUDIO_MODEL:-large-v3-turbo}
|
||||||
|
- AUDIO_DEVICE=${AUDIO_DEVICE:-cuda}
|
||||||
|
- AUDIO_COMPUTE_TYPE=${AUDIO_COMPUTE_TYPE:-int8}
|
||||||
|
- AUDIO_CACHE_DIR=${AUDIO_CACHE_DIR:-/root/.cache/huggingface}
|
||||||
|
|
||||||
|
# Transcription settings
|
||||||
|
- AUDIO_BEAM_SIZE=${AUDIO_BEAM_SIZE:-5}
|
||||||
|
- AUDIO_BEST_OF=${AUDIO_BEST_OF:-5}
|
||||||
|
- AUDIO_TEMPERATURE=${AUDIO_TEMPERATURE:-0.0}
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
- AUDIO_HOST=0.0.0.0
|
||||||
|
- AUDIO_PORT=54300
|
||||||
|
- AUDIO_LOG_LEVEL=${AUDIO_LOG_LEVEL:-INFO}
|
||||||
|
|
||||||
|
# Runtime config polling
|
||||||
|
- AUDIO_DASHBOARD_URL=${AUDIO_DASHBOARD_URL:-http://didiAI-dashboard:51300}
|
||||||
|
|
||||||
|
# Upload limits
|
||||||
|
- AUDIO_MAX_FILE_SIZE_MB=${AUDIO_MAX_FILE_SIZE_MB:-500}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
# Model cache (shared with other modules)
|
||||||
|
- ${AUDIO_CACHE_DIR:-/root/.cache/huggingface}:/root/.cache/huggingface
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
device_ids: ['0']
|
||||||
|
capabilities: [gpu]
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:54300/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- api
|
||||||
|
|
||||||
34
ai_platform/modules/audio/pyproject.toml
Normal file
34
ai_platform/modules/audio/pyproject.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
[project]
|
||||||
|
name = "audio"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Speech-to-text transcription service using faster-whisper"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.32.0",
|
||||||
|
"python-multipart>=0.0.9",
|
||||||
|
"pydantic>=2.0",
|
||||||
|
"pydantic-settings>=2.0",
|
||||||
|
"faster-whisper>=1.0.0",
|
||||||
|
"prometheus-fastapi-instrumentator>=7.0.0",
|
||||||
|
"opentelemetry-instrumentation-fastapi>=0.50b0",
|
||||||
|
"opentelemetry-exporter-otlp-proto-grpc>=1.30.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=4.0",
|
||||||
|
"ruff>=0.8",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/audio"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
extend = "../../ruff.toml"
|
||||||
3
ai_platform/modules/audio/src/audio/__init__.py
Normal file
3
ai_platform/modules/audio/src/audio/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Audio transcription service using faster-whisper."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
523
ai_platform/modules/audio/src/audio/app.py
Normal file
523
ai_platform/modules/audio/src/audio/app.py
Normal file
|
|
@ -0,0 +1,523 @@
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
|
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||||
|
|
||||||
|
from .runtime_config import RuntimeConfigClient
|
||||||
|
from .schemas import TranscriptionResponse, TranscriptionSegment
|
||||||
|
from .settings import settings
|
||||||
|
from .transcriber import get_transcriber
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, settings.log_level.upper()),
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Runtime config (instantiated here so it's reachable from request handlers via app.state)
|
||||||
|
runtime_config = RuntimeConfigClient(
|
||||||
|
dashboard_url=settings.dashboard_url,
|
||||||
|
live_log_logger_name="audio",
|
||||||
|
live_log_key="audio.log.level",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
"""App lifespan: load Whisper model + start runtime config polling."""
|
||||||
|
logger.info("Starting Audio Transcription API")
|
||||||
|
logger.info(f"Model: {settings.model}")
|
||||||
|
logger.info(f"Device: {settings.device}")
|
||||||
|
|
||||||
|
# Initialize transcriber (loads model)
|
||||||
|
get_transcriber()
|
||||||
|
|
||||||
|
await runtime_config.start()
|
||||||
|
app.state.runtime_config = runtime_config
|
||||||
|
|
||||||
|
logger.info("Startup complete")
|
||||||
|
yield
|
||||||
|
|
||||||
|
logger.info("Shutting down Audio Transcription API")
|
||||||
|
await runtime_config.stop()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Audio Transcription API",
|
||||||
|
description="Speech-to-text transcription using faster-whisper",
|
||||||
|
version="0.1.0",
|
||||||
|
servers=[{"url": settings.external_url, "description": "Audio Transcription API"}],
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- observability
|
||||||
|
|
||||||
|
|
||||||
|
# Prometheus /metrics + OTel tracing (no-op if deps missing or OTEL endpoint unset)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
|
||||||
|
from prometheus_fastapi_instrumentator import Instrumentator as _Inst # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
_Inst(should_group_status_codes=True).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
|
||||||
|
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
import os as _os # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
_otel_ep = _os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||||
|
|
||||||
|
|
||||||
|
if _otel_ep:
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry import trace as _trace # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry.sdk.resources import Resource as _R # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry.sdk.trace import TracerProvider as _TP # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor as _BSP # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as _Exp # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as _FInst # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
_provider = _TP(resource=_R.create({"service.name": _os.environ.get("OTEL_SERVICE_NAME", "didiAI-audio-api")}))
|
||||||
|
|
||||||
|
|
||||||
|
_provider.add_span_processor(_BSP(_Exp(endpoint=_otel_ep, insecure=True)))
|
||||||
|
|
||||||
|
|
||||||
|
_trace.set_tracer_provider(_provider)
|
||||||
|
|
||||||
|
|
||||||
|
_FInst.instrument_app(app)
|
||||||
|
|
||||||
|
|
||||||
|
print(f"[otel] didiAI-audio-api instrumented -> {_otel_ep}")
|
||||||
|
|
||||||
|
|
||||||
|
except ImportError as _e:
|
||||||
|
|
||||||
|
|
||||||
|
print(f"[otel] skip: {_e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
def list_models():
|
||||||
|
"""List available models (OpenAI-compatible)."""
|
||||||
|
return {
|
||||||
|
"object": "list",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"id": settings.model,
|
||||||
|
"object": "model",
|
||||||
|
"created": 1700000000,
|
||||||
|
"owned_by": "openai",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_url(url: str) -> tuple[bytes, str]:
|
||||||
|
"""Download audio from URL. Returns (content, file_extension)."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
suffix = Path(parsed.path).suffix or ".mp3"
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(follow_redirects=True, timeout=120.0) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
return resp.content, suffix
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_audio_content(
|
||||||
|
file: Optional[UploadFile],
|
||||||
|
url: Optional[str],
|
||||||
|
) -> tuple[bytes, str]:
|
||||||
|
"""Get audio content from file upload or URL. Returns (content, suffix)."""
|
||||||
|
if file and url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Provide either 'file' or 'url', not both.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if url:
|
||||||
|
try:
|
||||||
|
content, suffix = await _download_url(url)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Failed to download URL ({e.response.status_code}): {url}",
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Failed to download URL: {e}",
|
||||||
|
)
|
||||||
|
elif file:
|
||||||
|
content = await file.read()
|
||||||
|
suffix = Path(file.filename).suffix if file.filename else ".mp3"
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Either 'file' or 'url' is required.",
|
||||||
|
)
|
||||||
|
|
||||||
|
file_size_mb = len(content) / (1024 * 1024)
|
||||||
|
if file_size_mb > settings.max_file_size_mb:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=413,
|
||||||
|
detail=f"File too large: {file_size_mb:.1f}MB (max: {settings.max_file_size_mb}MB)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return content, suffix
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/audio/transcriptions")
|
||||||
|
async def create_transcription(
|
||||||
|
file: Optional[UploadFile] = File(default=None),
|
||||||
|
url: Optional[str] = Form(default=None),
|
||||||
|
model: str = Form(default=None),
|
||||||
|
language: str = Form(default=None),
|
||||||
|
prompt: str = Form(default=None),
|
||||||
|
response_format: str = Form(default="json"),
|
||||||
|
temperature: float = Form(default=0.0),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Transcribe audio file (OpenAI-compatible endpoint).
|
||||||
|
|
||||||
|
Accepts either a file upload or a URL to download.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: Audio file to transcribe (multipart upload)
|
||||||
|
url: URL to download audio from (alternative to file upload)
|
||||||
|
model: Model to use (ignored, uses configured model)
|
||||||
|
language: Language code (auto-detect if None)
|
||||||
|
prompt: Optional text prompt
|
||||||
|
response_format: Response format: json, text, or verbose_json
|
||||||
|
temperature: Sampling temperature
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Transcription response in requested format
|
||||||
|
"""
|
||||||
|
content, suffix = await _get_audio_content(file, url)
|
||||||
|
|
||||||
|
# Save to temp location
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
|
||||||
|
tmp_file.write(content)
|
||||||
|
tmp_path = tmp_file.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Transcribe
|
||||||
|
transcriber = get_transcriber()
|
||||||
|
full_text, metadata = transcriber.transcribe(
|
||||||
|
audio_path=tmp_path,
|
||||||
|
language=language if language else None,
|
||||||
|
initial_prompt=prompt if prompt else None,
|
||||||
|
temperature=temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format response based on response_format
|
||||||
|
if response_format == "text":
|
||||||
|
return PlainTextResponse(content=full_text)
|
||||||
|
|
||||||
|
elif response_format == "verbose_json":
|
||||||
|
# Verbose format with segments
|
||||||
|
segments = [TranscriptionSegment(**seg) for seg in metadata["segments"]]
|
||||||
|
response = TranscriptionResponse(
|
||||||
|
text=full_text,
|
||||||
|
language=metadata["language"],
|
||||||
|
duration=metadata["duration"],
|
||||||
|
segments=segments,
|
||||||
|
)
|
||||||
|
return JSONResponse(content=response.model_dump())
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Default JSON format (text only)
|
||||||
|
response = TranscriptionResponse(
|
||||||
|
text=full_text,
|
||||||
|
language=metadata["language"],
|
||||||
|
duration=metadata["duration"],
|
||||||
|
)
|
||||||
|
return JSONResponse(content=response.model_dump())
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Transcription error: {e}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail=f"Transcription failed: {str(e)}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Clean up temp file
|
||||||
|
try:
|
||||||
|
Path(tmp_path).unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/info")
|
||||||
|
def get_component_info():
|
||||||
|
"""
|
||||||
|
Get component information for service catalog.
|
||||||
|
|
||||||
|
Returns complete metadata about this service including:
|
||||||
|
- Resource information (component metadata)
|
||||||
|
- Available models (Whisper variants)
|
||||||
|
- Available functions (API endpoints)
|
||||||
|
|
||||||
|
This endpoint is used by the catalog-api to aggregate service information
|
||||||
|
and by backend systems to populate the catalog database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Component information matching catalog.resources, catalog.models,
|
||||||
|
and catalog.functions schemas.
|
||||||
|
"""
|
||||||
|
# Build resource information (maps to catalog.resources)
|
||||||
|
resource = {
|
||||||
|
"name": "Audio Transcription Service",
|
||||||
|
"slug": "audio-transcription",
|
||||||
|
"resource_type": "api_service",
|
||||||
|
"provider": "internal",
|
||||||
|
"base_url": f"http://audio-api:{settings.port}",
|
||||||
|
"configuration": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"port": settings.port,
|
||||||
|
"external_url": "http://localhost:8203",
|
||||||
|
"model": settings.model,
|
||||||
|
"device": settings.device,
|
||||||
|
"compute_type": settings.compute_type,
|
||||||
|
},
|
||||||
|
"authentication": {
|
||||||
|
"type": "none",
|
||||||
|
"required": False,
|
||||||
|
},
|
||||||
|
"headers": {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
"Accept": "application/json",
|
||||||
|
},
|
||||||
|
"rate_limits": {
|
||||||
|
"enabled": False,
|
||||||
|
},
|
||||||
|
"cost_tracking": {
|
||||||
|
"enabled": False,
|
||||||
|
},
|
||||||
|
"tags": ["audio", "transcription", "whisper", "speech-to-text", "openai-compatible"],
|
||||||
|
"is_active": True,
|
||||||
|
"metadata": {
|
||||||
|
"category": "audio",
|
||||||
|
"gpu_required": settings.device == "cuda",
|
||||||
|
"status": "healthy",
|
||||||
|
"backend": "faster-whisper",
|
||||||
|
"max_file_size_mb": settings.max_file_size_mb,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Define models (maps to catalog.models)
|
||||||
|
models = [
|
||||||
|
{
|
||||||
|
"name": f"Whisper {settings.model}",
|
||||||
|
"slug": f"whisper-{settings.model}",
|
||||||
|
"provider": "openai",
|
||||||
|
"model_type": "audio",
|
||||||
|
"capabilities": [
|
||||||
|
"transcription",
|
||||||
|
"translation",
|
||||||
|
"language-detection",
|
||||||
|
"voice-activity-detection",
|
||||||
|
],
|
||||||
|
"configuration": {
|
||||||
|
"backend": "faster-whisper",
|
||||||
|
"device": settings.device,
|
||||||
|
"compute_type": settings.compute_type,
|
||||||
|
"beam_size": settings.beam_size,
|
||||||
|
"best_of": settings.best_of,
|
||||||
|
},
|
||||||
|
"endpoint": "http://localhost:8203/v1/audio/transcriptions",
|
||||||
|
"api_key_ref": None,
|
||||||
|
"tags": ["whisper", "audio", "transcription", settings.model, settings.device],
|
||||||
|
"is_active": True,
|
||||||
|
"metadata": {
|
||||||
|
"model_name": settings.model,
|
||||||
|
"cache_dir": settings.cache_dir,
|
||||||
|
"gpu_id": 1 if settings.device == "cuda" else None,
|
||||||
|
"vram_gb": 7 if settings.device == "cuda" and "large" in settings.model else None,
|
||||||
|
"quantization": settings.compute_type,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Define available functions (maps to catalog.functions)
|
||||||
|
functions = [
|
||||||
|
{
|
||||||
|
"name": "Audio Transcription",
|
||||||
|
"slug": "audio-transcription",
|
||||||
|
"category": "transcription",
|
||||||
|
"description": (
|
||||||
|
"Transcribe audio files to text using Whisper. "
|
||||||
|
"OpenAI-compatible endpoint supporting multiple response formats."
|
||||||
|
),
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"file": {
|
||||||
|
"type": "file",
|
||||||
|
"description": "Audio file to transcribe",
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Model identifier (ignored, uses configured model)",
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Language code (ISO 639-1). Auto-detect if not specified.",
|
||||||
|
},
|
||||||
|
"prompt": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional text prompt to guide the model",
|
||||||
|
},
|
||||||
|
"response_format": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["json", "text", "verbose_json"],
|
||||||
|
"default": "json",
|
||||||
|
"description": "Response format",
|
||||||
|
},
|
||||||
|
"temperature": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0.0,
|
||||||
|
"maximum": 1.0,
|
||||||
|
"default": 0.0,
|
||||||
|
"description": "Sampling temperature",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["file"],
|
||||||
|
},
|
||||||
|
"output_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {"type": "string", "description": "Transcribed text"},
|
||||||
|
"language": {"type": "string", "description": "Detected language"},
|
||||||
|
"duration": {"type": "number", "description": "Audio duration in seconds"},
|
||||||
|
"segments": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Transcription segments (only in verbose_json format)",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "integer"},
|
||||||
|
"start": {"type": "number"},
|
||||||
|
"end": {"type": "number"},
|
||||||
|
"text": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"implementation": {
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v1/audio/transcriptions",
|
||||||
|
"content_type": "multipart/form-data",
|
||||||
|
"timeout": 300,
|
||||||
|
"max_file_size_mb": settings.max_file_size_mb,
|
||||||
|
},
|
||||||
|
"endpoint": "http://localhost:8203/v1/audio/transcriptions",
|
||||||
|
"tags": ["audio", "transcription", "openai-compatible"],
|
||||||
|
"is_active": True,
|
||||||
|
"metadata": {
|
||||||
|
"openai_compatible": True,
|
||||||
|
"supports_streaming": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "List Audio Models",
|
||||||
|
"slug": "audio-list-models",
|
||||||
|
"category": "discovery",
|
||||||
|
"description": "List available Whisper models (OpenAI-compatible)",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
"output_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"object": {"type": "string", "const": "list"},
|
||||||
|
"data": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"object": {"type": "string"},
|
||||||
|
"created": {"type": "integer"},
|
||||||
|
"owned_by": {"type": "string"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"implementation": {
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/v1/models",
|
||||||
|
"timeout": 5,
|
||||||
|
},
|
||||||
|
"endpoint": "http://localhost:8203/v1/models",
|
||||||
|
"tags": ["discovery", "models"],
|
||||||
|
"is_active": True,
|
||||||
|
"metadata": {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"resource": resource,
|
||||||
|
"models": models,
|
||||||
|
"functions": functions,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"audio.app:app",
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
log_level=settings.log_level.lower(),
|
||||||
|
)
|
||||||
157
ai_platform/modules/audio/src/audio/runtime_config.py
Normal file
157
ai_platform/modules/audio/src/audio/runtime_config.py
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""Runtime config client — fetches config overrides from dashboard.
|
||||||
|
|
||||||
|
Polls the dashboard /api/config endpoint periodically and caches values
|
||||||
|
in memory. All accessors fall back to caller-supplied defaults if the
|
||||||
|
dashboard is unreachable or the key is missing.
|
||||||
|
|
||||||
|
Single source of truth = dashboard `KNOWN_KEYS`. This client carries no
|
||||||
|
local default registry; consumers pass their own fallback (typically the
|
||||||
|
pydantic settings value).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeConfigClient:
|
||||||
|
"""Polls dashboard /api/config and caches values in-process."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dashboard_url: str | None,
|
||||||
|
poll_interval_seconds: int = 30,
|
||||||
|
request_timeout: float = 5.0,
|
||||||
|
live_log_logger_name: str | None = None,
|
||||||
|
live_log_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.dashboard_url = dashboard_url.rstrip("/") if dashboard_url else None
|
||||||
|
self.poll_interval = poll_interval_seconds
|
||||||
|
self._timeout = request_timeout
|
||||||
|
self._cache: dict[str, Any] = {}
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
self._task: asyncio.Task[None] | None = None
|
||||||
|
self._enabled = bool(dashboard_url)
|
||||||
|
# Optional auto-apply: re-set log level on the named logger when the
|
||||||
|
# configured key changes value (e.g., `llm.log.level`).
|
||||||
|
self._live_log_logger_name = live_log_logger_name
|
||||||
|
self._live_log_key = live_log_key
|
||||||
|
self._last_log_level: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
v = self._cache.get(key)
|
||||||
|
return v if v is not None else default
|
||||||
|
|
||||||
|
def get_bool(self, key: str, default: bool = False) -> bool:
|
||||||
|
v = self._cache.get(key)
|
||||||
|
return bool(v) if v is not None else default
|
||||||
|
|
||||||
|
def get_int(self, key: str, default: int = 0) -> int:
|
||||||
|
v = self._cache.get(key)
|
||||||
|
if v is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_float(self, key: str, default: float = 0.0) -> float:
|
||||||
|
v = self._cache.get(key)
|
||||||
|
if v is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_str(self, key: str, default: str = "") -> str:
|
||||||
|
v = self._cache.get(key)
|
||||||
|
return str(v) if v is not None else default
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if not self._enabled:
|
||||||
|
logger.info("RuntimeConfigClient disabled (no dashboard URL)")
|
||||||
|
return
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(
|
||||||
|
connect=2.0, read=self._timeout, write=2.0, pool=5.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Fetch once synchronously so first requests already have overrides
|
||||||
|
await self._refresh()
|
||||||
|
self._task = asyncio.create_task(self._loop())
|
||||||
|
logger.info(
|
||||||
|
"RuntimeConfigClient started (polling %s every %ds, %d keys cached)",
|
||||||
|
self.dashboard_url,
|
||||||
|
self.poll_interval,
|
||||||
|
len(self._cache),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||||
|
await self._task
|
||||||
|
self._task = None
|
||||||
|
if self._client is not None and not self._client.is_closed:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self.poll_interval)
|
||||||
|
await self._refresh()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Config poll failed: %s", e)
|
||||||
|
|
||||||
|
async def _refresh(self) -> None:
|
||||||
|
if self._client is None or self.dashboard_url is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = await self._client.get(f"{self.dashboard_url}/api/config")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Config refresh failed: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
items = data.get("items", {})
|
||||||
|
new_cache: dict[str, Any] = {}
|
||||||
|
for key, entry in items.items():
|
||||||
|
new_cache[key] = entry.get("value")
|
||||||
|
self._cache = new_cache
|
||||||
|
self._maybe_apply_log_level()
|
||||||
|
|
||||||
|
def _maybe_apply_log_level(self) -> None:
|
||||||
|
"""Re-apply log level live if configured key changed."""
|
||||||
|
if not self._live_log_key or not self._live_log_logger_name:
|
||||||
|
return
|
||||||
|
new_level = self.get_str(self._live_log_key)
|
||||||
|
if not new_level:
|
||||||
|
return
|
||||||
|
if new_level == self._last_log_level:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
level_int = logging.getLevelName(new_level.upper())
|
||||||
|
if isinstance(level_int, int):
|
||||||
|
logging.getLogger(self._live_log_logger_name).setLevel(level_int)
|
||||||
|
self._last_log_level = new_level
|
||||||
|
logger.info(
|
||||||
|
"Log level for %s changed to %s (via runtime config)",
|
||||||
|
self._live_log_logger_name,
|
||||||
|
new_level,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to apply log level %s: %s", new_level, e)
|
||||||
44
ai_platform/modules/audio/src/audio/schemas.py
Normal file
44
ai_platform/modules/audio/src/audio/schemas.py
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionSegment(BaseModel):
|
||||||
|
"""A segment of transcribed audio."""
|
||||||
|
|
||||||
|
id: int = Field(..., description="Segment ID")
|
||||||
|
seek: int = Field(..., description="Seek position in audio")
|
||||||
|
start: float = Field(..., description="Start time in seconds")
|
||||||
|
end: float = Field(..., description="End time in seconds")
|
||||||
|
text: str = Field(..., description="Transcribed text")
|
||||||
|
tokens: list[int] = Field(..., description="Token IDs")
|
||||||
|
temperature: float = Field(..., description="Temperature used")
|
||||||
|
avg_logprob: float = Field(..., description="Average log probability")
|
||||||
|
compression_ratio: float = Field(..., description="Compression ratio")
|
||||||
|
no_speech_prob: float = Field(..., description="No speech probability")
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionResponse(BaseModel):
|
||||||
|
"""OpenAI-compatible transcription response."""
|
||||||
|
|
||||||
|
text: str = Field(..., description="Full transcription text")
|
||||||
|
language: Optional[str] = Field(None, description="Detected language code")
|
||||||
|
duration: Optional[float] = Field(None, description="Audio duration in seconds")
|
||||||
|
segments: Optional[list[TranscriptionSegment]] = Field(None, description="Detailed segments")
|
||||||
|
|
||||||
|
|
||||||
|
class TranscriptionRequest(BaseModel):
|
||||||
|
"""Transcription request parameters."""
|
||||||
|
|
||||||
|
model: str = Field(default="large-v3-turbo", description="Whisper model to use")
|
||||||
|
language: Optional[str] = Field(None, description="Language code (auto-detect if None)")
|
||||||
|
prompt: Optional[str] = Field(None, description="Optional text prompt")
|
||||||
|
response_format: Literal["json", "text", "verbose_json"] = Field(
|
||||||
|
default="json",
|
||||||
|
description="Response format"
|
||||||
|
)
|
||||||
|
temperature: float = Field(default=0.0, ge=0.0, le=1.0, description="Sampling temperature")
|
||||||
|
timestamp_granularities: list[Literal["segment", "word"]] = Field(
|
||||||
|
default=["segment"],
|
||||||
|
description="Timestamp granularities"
|
||||||
|
)
|
||||||
47
ai_platform/modules/audio/src/audio/settings.py
Normal file
47
ai_platform/modules/audio/src/audio/settings.py
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="AUDIO_",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Whisper model configuration
|
||||||
|
model: str = Field(default="large-v3-turbo", description="Whisper model name")
|
||||||
|
device: str = Field(default="cuda", description="Device: cuda or cpu")
|
||||||
|
compute_type: str = Field(default="int8", description="Compute type: float16, int8, int8_float16")
|
||||||
|
|
||||||
|
# Model cache
|
||||||
|
cache_dir: str = Field(default="/root/.cache/huggingface", description="Model cache directory")
|
||||||
|
|
||||||
|
# Transcription settings
|
||||||
|
beam_size: int = Field(default=5, ge=1, le=10, description="Beam size for decoding")
|
||||||
|
best_of: int = Field(default=5, ge=1, le=10, description="Number of candidates when sampling")
|
||||||
|
temperature: float = Field(default=0.0, ge=0.0, le=1.0, description="Temperature for sampling")
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
host: str = Field(default="0.0.0.0")
|
||||||
|
port: int = Field(default=54300)
|
||||||
|
log_level: str = Field(default="INFO")
|
||||||
|
external_url: str = Field(
|
||||||
|
description="External URL for OpenAPI spec (e.g., http://10.11.10.42:54300). REQUIRED.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload limits
|
||||||
|
max_file_size_mb: int = Field(default=500, description="Max upload file size in MB")
|
||||||
|
|
||||||
|
# Runtime config (dashboard polling)
|
||||||
|
dashboard_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Optional dashboard base URL. When set, runtime_config polls "
|
||||||
|
"/api/config every 30s for live overrides."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
106
ai_platform/modules/audio/src/audio/transcriber.py
Normal file
106
ai_platform/modules/audio/src/audio/transcriber.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class Transcriber:
|
||||||
|
"""Wrapper for faster-whisper transcription."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the Whisper model."""
|
||||||
|
logger.info(f"Loading Whisper model: {settings.model}")
|
||||||
|
logger.info(f"Device: {settings.device}, Compute type: {settings.compute_type}")
|
||||||
|
|
||||||
|
self.model = WhisperModel(
|
||||||
|
settings.model,
|
||||||
|
device=settings.device,
|
||||||
|
compute_type=settings.compute_type,
|
||||||
|
download_root=settings.cache_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Whisper model loaded successfully")
|
||||||
|
|
||||||
|
def transcribe(
|
||||||
|
self,
|
||||||
|
audio_path: str | Path,
|
||||||
|
language: Optional[str] = None,
|
||||||
|
initial_prompt: Optional[str] = None,
|
||||||
|
temperature: float = 0.0,
|
||||||
|
) -> tuple[str, dict]:
|
||||||
|
"""
|
||||||
|
Transcribe audio file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
audio_path: Path to audio file
|
||||||
|
language: Language code (None for auto-detection)
|
||||||
|
initial_prompt: Optional prompt text
|
||||||
|
temperature: Sampling temperature
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (full_text, metadata_dict)
|
||||||
|
"""
|
||||||
|
logger.info(f"Transcribing: {audio_path}")
|
||||||
|
|
||||||
|
segments, info = self.model.transcribe(
|
||||||
|
str(audio_path),
|
||||||
|
language=language,
|
||||||
|
initial_prompt=initial_prompt,
|
||||||
|
beam_size=settings.beam_size,
|
||||||
|
best_of=settings.best_of,
|
||||||
|
temperature=temperature,
|
||||||
|
vad_filter=True,
|
||||||
|
vad_parameters=dict(min_silence_duration_ms=500),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Collect segments
|
||||||
|
all_segments = []
|
||||||
|
full_text_parts = []
|
||||||
|
|
||||||
|
for segment in segments:
|
||||||
|
all_segments.append({
|
||||||
|
"id": segment.id,
|
||||||
|
"seek": segment.seek,
|
||||||
|
"start": segment.start,
|
||||||
|
"end": segment.end,
|
||||||
|
"text": segment.text,
|
||||||
|
"tokens": segment.tokens,
|
||||||
|
"temperature": segment.temperature,
|
||||||
|
"avg_logprob": segment.avg_logprob,
|
||||||
|
"compression_ratio": segment.compression_ratio,
|
||||||
|
"no_speech_prob": segment.no_speech_prob,
|
||||||
|
})
|
||||||
|
full_text_parts.append(segment.text)
|
||||||
|
|
||||||
|
full_text = "".join(full_text_parts).strip()
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"language": info.language,
|
||||||
|
"language_probability": info.language_probability,
|
||||||
|
"duration": info.duration,
|
||||||
|
"duration_after_vad": info.duration_after_vad,
|
||||||
|
"all_language_probs": info.all_language_probs,
|
||||||
|
"segments": all_segments,
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Transcription complete: {len(all_segments)} segments, {info.duration:.2f}s")
|
||||||
|
|
||||||
|
return full_text, metadata
|
||||||
|
|
||||||
|
|
||||||
|
# Global transcriber instance
|
||||||
|
_transcriber: Optional[Transcriber] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_transcriber() -> Transcriber:
|
||||||
|
"""Get or create the global transcriber instance."""
|
||||||
|
global _transcriber
|
||||||
|
if _transcriber is None:
|
||||||
|
_transcriber = Transcriber()
|
||||||
|
return _transcriber
|
||||||
0
ai_platform/modules/audio/tests/__init__.py
Normal file
0
ai_platform/modules/audio/tests/__init__.py
Normal file
225
ai_platform/modules/catalog-api/API.md
Normal file
225
ai_platform/modules/catalog-api/API.md
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
# Catalog API Documentation
|
||||||
|
|
||||||
|
Service catalog and discovery API that aggregates component information from all ML services.
|
||||||
|
|
||||||
|
## Base URL
|
||||||
|
|
||||||
|
```
|
||||||
|
{BASE_URL}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Docker (internal):** `http://didiAI-catalog-api:11000`
|
||||||
|
- **Via gateway:** `http://<host>:11000/catalog/`
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
No authentication required on direct access. When accessed via the gateway, Bearer token authentication is enforced by nginx.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List Components
|
||||||
|
|
||||||
|
List all registered components with full metadata (resource info, models, functions).
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/components
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"component_id": "llm-inference",
|
||||||
|
"base_url": "http://didiAI-llm-api:14011",
|
||||||
|
"resource": { "name": "LLM Inference Gateway", "slug": "llm-inference", ... },
|
||||||
|
"models": [...],
|
||||||
|
"functions": [...]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 4,
|
||||||
|
"errors": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Component
|
||||||
|
|
||||||
|
Get metadata for a specific component by ID.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/components/{component_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Path Parameters:**
|
||||||
|
|
||||||
|
| Parameter | Type | Description |
|
||||||
|
|-----------|------|-------------|
|
||||||
|
| `component_id` | string | Component ID (e.g., `llm-inference`, `audio-transcription`, `video-analysis`, `web-factcheck`) |
|
||||||
|
|
||||||
|
**Response:** Same structure as a single component from `/v1/components`.
|
||||||
|
|
||||||
|
**Error (404):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"detail": "Component 'unknown' not found"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List Models
|
||||||
|
|
||||||
|
List all available models across all components.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/models
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": "Qwen3.5-35B-A3B",
|
||||||
|
"slug": "qwen3.5",
|
||||||
|
"provider": "vllm",
|
||||||
|
"model_type": "llm",
|
||||||
|
"component_id": "llm-inference",
|
||||||
|
"component_name": "LLM Inference Gateway"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List Functions
|
||||||
|
|
||||||
|
List all available functions/endpoints across all components.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/functions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"functions": [
|
||||||
|
{
|
||||||
|
"name": "Chat Completions",
|
||||||
|
"slug": "chat-completions",
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/v1/chat/completions",
|
||||||
|
"component_id": "llm-inference"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### System Status
|
||||||
|
|
||||||
|
Aggregated health status of all components.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/status
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"component_id": "llm-inference",
|
||||||
|
"url": "http://didiAI-llm-api:14011",
|
||||||
|
"reachable": true,
|
||||||
|
"healthy": true,
|
||||||
|
"status_code": 200
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"healthy_count": 4,
|
||||||
|
"total_count": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Status values:** `healthy` (all ok), `degraded` (some ok), `unhealthy` (none ok).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Aggregated OpenAPI Spec
|
||||||
|
|
||||||
|
Combined OpenAPI 3.1.0 specification from all components.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/openapi
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** Full OpenAPI JSON spec with paths prefixed by `/{component_id}` and schemas prefixed by `{component_id}_`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Swagger UI
|
||||||
|
|
||||||
|
Interactive API documentation (Swagger UI) for the aggregated spec.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/docs
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ReDoc
|
||||||
|
|
||||||
|
Alternative API documentation (ReDoc) for the aggregated spec.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/redoc
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Component OpenAPI Spec
|
||||||
|
|
||||||
|
Raw OpenAPI spec for a single component.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/openapi/component/{component_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Responses
|
||||||
|
|
||||||
|
| Code | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| 404 | Component not found |
|
||||||
|
| 500 | Internal error (component unreachable) |
|
||||||
|
|
||||||
|
## Request Headers
|
||||||
|
|
||||||
|
| Header | Required | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `Accept` | No | `application/json` (default) |
|
||||||
146
ai_platform/modules/catalog-api/INDEX.md
Normal file
146
ai_platform/modules/catalog-api/INDEX.md
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
# catalog-api — INDEX
|
||||||
|
|
||||||
|
Catalog API for the DIDI AI platform. It is a **service registry and discovery gateway**: it aggregates `/v1/info` responses from each ML component (LLM, Audio, Video, Web) and re-exposes a unified view (components, models, functions, status) plus a merged OpenAPI 3.1 spec with Swagger UI / ReDoc. Production port `11000`, container `didiAI-catalog-api`. Read-only HTTP aggregator — no database, no writes.
|
||||||
|
|
||||||
|
- **Stack:** Python 3.11, FastAPI, httpx (async), pydantic-settings, uv, uvicorn
|
||||||
|
- **Container:** `didiAI-catalog-api`
|
||||||
|
- **Internal URL:** `http://didiAI-catalog-api:11000`
|
||||||
|
- **Production host:** `http://10.11.10.42` (per `CATALOG_EXTERNAL_URL` in `deploy/.env.example`)
|
||||||
|
- **Network:** Docker external network `didi-network` (shared with the other ai_platform modules)
|
||||||
|
- **Sister CLAUDE.md (platform):** `/home/admin365/didi_mono/ai_platform/CLAUDE.md`
|
||||||
|
|
||||||
|
> Note: despite the name, this module is **not** a knowledge-graph / atom catalog. It is a *service catalog* (think "service registry" in the microservices sense). It does not talk to PostgreSQL, Atomic, Redis, or didi-brain.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ce face
|
||||||
|
|
||||||
|
- Aggregates static metadata from each ML component by calling `GET /v1/info` on the configured backends.
|
||||||
|
- Returns a single unified response with:
|
||||||
|
- **Resources** — component descriptor (`name`, `slug`, `resource_type`, ...).
|
||||||
|
- **Models** — every model exposed by every backend (LLM, Whisper, vision, etc.).
|
||||||
|
- **Functions** — every endpoint/function each backend advertises.
|
||||||
|
- Probes liveness of every backend and reports aggregated health (`healthy` / `degraded` / `unhealthy`).
|
||||||
|
- Builds a **merged OpenAPI 3.1.0 document** (paths prefixed with `/{component_id}`, schemas prefixed with `{component_id}_`) and serves it as JSON, Swagger UI, and ReDoc — so a frontend or backend can consume a single contract for all GPU services.
|
||||||
|
- Tolerant to partial outages: if a component is unreachable it is recorded under `errors` and skipped, the rest of the catalog still serves.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API endpoints
|
||||||
|
|
||||||
|
Source: `src/catalog_api/app.py` and `API.md`.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| GET | `/health` | Liveness probe (`{"status":"ok"}`). |
|
||||||
|
| GET | `/v1/components` | List all registered components with full metadata (resource + models + functions). |
|
||||||
|
| GET | `/v1/components/{component_id}` | Full metadata for one component (404 if unknown). |
|
||||||
|
| GET | `/v1/models` | Flattened list of every model across components. |
|
||||||
|
| GET | `/v1/functions` | Flattened list of every function/endpoint across components. |
|
||||||
|
| GET | `/v1/status` | Aggregated reachability + health of all components (`healthy_count`/`total_count`). |
|
||||||
|
| GET | `/v1/openapi` | Merged OpenAPI 3.1 spec (all components, prefixed). |
|
||||||
|
| GET | `/v1/docs` | Swagger UI for the merged spec. |
|
||||||
|
| GET | `/v1/redoc` | ReDoc for the merged spec. |
|
||||||
|
| GET | `/v1/openapi/component/{component_id}` | Raw OpenAPI spec of a single component (passthrough). |
|
||||||
|
|
||||||
|
Status values from `/v1/status`: `healthy` (all reachable), `degraded` (some reachable), `unhealthy` (none reachable).
|
||||||
|
|
||||||
|
When accessed through the gateway, Bearer-token auth is enforced by nginx (`<host>:11000/catalog/`); direct access is unauthenticated by design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
+-----------------------------------------------------+
|
||||||
|
| Catalog API (:11000) — didiAI-catalog-api |
|
||||||
|
| - Calls /v1/info on each backend |
|
||||||
|
| - Merges OpenAPI specs, exposes Swagger UI / ReDoc |
|
||||||
|
| - Stateless, no DB |
|
||||||
|
+-------+----------+----------+----------+------------+
|
||||||
|
| | | |
|
||||||
|
v v v v
|
||||||
|
LLM 14011 Audio 54300 Video 54600 Web 51100
|
||||||
|
didiAI-llm-api -audio-api -video-api -web-api
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Pure aggregator** — no persistence, no caching layer; a fresh fan-out happens per request via `httpx.AsyncClient`.
|
||||||
|
- **Component list is config-driven** (`CatalogSettings.get_components()` in `settings.py`): a component with an empty URL is silently dropped (used today to disable Video by setting `CATALOG_VIDEO_URL=""`).
|
||||||
|
- **External-vs-internal URL split** — internal URLs (`*_URL`) are used for live calls inside the Docker network; the `CATALOG_EXTERNAL_URL` + `*_EXTERNAL_PORT` pair is the public base URL injected into the merged OpenAPI `servers:` so that external clients hit the right hostnames/ports.
|
||||||
|
|
||||||
|
### How it is consumed
|
||||||
|
|
||||||
|
- **Frontend / API gateway** — fetches `/v1/openapi` to expose Swagger UI for the whole platform; fetches `/v1/status` for a system-health widget.
|
||||||
|
- **Backend integrations** — pull `/v1/components` to populate their own catalog tables (`catalog.resources`, `catalog.models`, `catalog.functions`), as illustrated in `README.md` § Use Cases.
|
||||||
|
- **Service discovery** — clients query `/v1/models` to find a model by `model_type` (e.g., all `vision` models) without hard-coding hosts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structura fișiere
|
||||||
|
|
||||||
|
```
|
||||||
|
catalog-api/
|
||||||
|
├── README.md Overview, quick-start, configuration, use cases
|
||||||
|
├── API.md Endpoint reference (request/response shapes)
|
||||||
|
├── INDEX.md This file
|
||||||
|
├── pyproject.toml Hatchling package, FastAPI/httpx/pydantic-settings deps
|
||||||
|
└── src/catalog_api/
|
||||||
|
├── __init__.py version = "0.1.0"
|
||||||
|
├── app.py FastAPI app — all endpoints + OpenAPI merger (~21 KB, single module)
|
||||||
|
└── settings.py CatalogSettings (env prefix CATALOG_) and Component model
|
||||||
|
└── deploy/
|
||||||
|
├── Dockerfile Multi-stage build: python:3.11.12-slim + uv 0.10, runs uvicorn
|
||||||
|
├── docker-compose.yml Defines didiAI-catalog-api on didi-network, expose:11000 only
|
||||||
|
├── deploy.sh Wrapper: loads .env, validates CATALOG_EXTERNAL_URL, runs compose
|
||||||
|
├── .env.example Documented environment variables
|
||||||
|
└── .env Local environment (CATALOG_EXTERNAL_URL=...)
|
||||||
|
```
|
||||||
|
|
||||||
|
Implementation footprint is tiny: one `app.py` (all endpoints + OpenAPI merger live there) plus one `settings.py`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All settings come from environment variables with prefix `CATALOG_` (see `src/catalog_api/settings.py`).
|
||||||
|
|
||||||
|
| Variable | Default | Purpose |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| `CATALOG_HOST` | `0.0.0.0` | Bind address. |
|
||||||
|
| `CATALOG_PORT` | `11000` | Bind port. |
|
||||||
|
| `CATALOG_LOG_LEVEL` | `INFO` | Python logging level. |
|
||||||
|
| `CATALOG_EXTERNAL_URL` | **required** | Public base URL (e.g., `http://10.11.10.42`) injected into the merged OpenAPI `servers:`. `deploy.sh` aborts if missing. |
|
||||||
|
| `CATALOG_LLM_URL` | `http://didiAI-llm-api:14011` | LLM Inference internal URL. |
|
||||||
|
| `CATALOG_AUDIO_URL` | `http://didiAI-audio-api:54300` | Audio API internal URL. |
|
||||||
|
| `CATALOG_VIDEO_URL` | *(empty)* | Video Analysis internal URL — empty string disables Video. |
|
||||||
|
| `CATALOG_WEB_URL` | `http://didiAI-web-api:51100` | Web API internal URL. |
|
||||||
|
| `CATALOG_LLM_EXTERNAL_PORT` | `14011` | External port advertised in the merged OpenAPI for LLM. |
|
||||||
|
| `CATALOG_AUDIO_EXTERNAL_PORT` | `54300` | External port for Audio. |
|
||||||
|
| `CATALOG_VIDEO_EXTERNAL_PORT` | `54600` | External port for Video. |
|
||||||
|
| `CATALOG_WEB_EXTERNAL_PORT` | `51100` | External port for Web. |
|
||||||
|
| `CATALOG_COMPONENT_TIMEOUT` | `10` | Per-call httpx timeout in seconds. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
- **Docker compose** (`deploy/docker-compose.yml`): builds `didiai-catalog-api`, attaches to external network `didi-network`, only `expose: 11000` (no host port — traffic comes through the platform gateway). Healthcheck hits `http://localhost:11000/health` every 30 s, restart policy `unless-stopped`.
|
||||||
|
- **Dockerfile** (`deploy/Dockerfile`): two-stage build using `ghcr.io/astral-sh/uv:0.10` for dependency install, then a slim `python:3.11.12-slim` runtime that runs `python -m uvicorn catalog_api.app:app`.
|
||||||
|
- **Helper script** (`deploy/deploy.sh`): loads `.env`, validates `CATALOG_EXTERNAL_URL`, supports `--detach`, `--down`, `--logs`.
|
||||||
|
- **Local dev** (per README.md): `uv sync && uv run python -m uvicorn catalog_api.app:app --host 0.0.0.0 --port 11000` (requires the listed components reachable on the network).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related modules
|
||||||
|
|
||||||
|
This service stands on top of the rest of the `ai_platform/modules/*` family — they are its data sources:
|
||||||
|
|
||||||
|
- `llm-inference` (port `14011`, container `didiAI-llm-api`) — chat, embeddings, rerank.
|
||||||
|
- `audio` (port `54300`, container `didiAI-audio-api`) — transcription / TTS.
|
||||||
|
- `video-analysis` (port `54600`, container `didiAI-video-api`) — vision pipelines (currently disabled by default in the .env example).
|
||||||
|
- `web` (port `51100`, container `didiAI-web-api`) — fact-check / web crawler.
|
||||||
|
- `dashboard` — primary frontend consumer of the merged OpenAPI / `/v1/status`.
|
||||||
|
|
||||||
|
It is **independent** of:
|
||||||
|
- `didi-brain`, Atomic / knowledge-graph services, PostgreSQL, Redis — none of these are accessed.
|
||||||
|
- The orchestration-layer (`agent-v3`) does not currently consume this catalog; it talks to the GPU services directly.
|
||||||
253
ai_platform/modules/catalog-api/README.md
Normal file
253
ai_platform/modules/catalog-api/README.md
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
# Catalog API
|
||||||
|
|
||||||
|
**Service catalog and discovery** - aggregates component information from all ML services.
|
||||||
|
|
||||||
|
## What It Does
|
||||||
|
|
||||||
|
This module provides a unified API to discover and query all available ML services, models, and endpoints in the system. It aggregates `/v1/info` from all registered components and exposes:
|
||||||
|
|
||||||
|
- Component metadata (resources)
|
||||||
|
- Available models across all services
|
||||||
|
- Available functions/endpoints
|
||||||
|
- Health status of all components
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
**Required:**
|
||||||
|
- All global prerequisites (see main [README.md](../../README.md))
|
||||||
|
- Docker network `deploy_default` (shared with other modules)
|
||||||
|
- At least one other module running (llm-inference, audio, video-analysis, or web)
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy/
|
||||||
|
|
||||||
|
# Start the catalog API
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Check health
|
||||||
|
curl http://localhost:11000/health
|
||||||
|
|
||||||
|
# List all components
|
||||||
|
curl http://localhost:11000/v1/components | jq
|
||||||
|
|
||||||
|
# List all models
|
||||||
|
curl http://localhost:11000/v1/models | jq
|
||||||
|
|
||||||
|
# Get component status
|
||||||
|
curl http://localhost:11000/v1/status | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Method | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `/health` | GET | Health check |
|
||||||
|
| `/v1/components` | GET | List all components with full metadata |
|
||||||
|
| `/v1/components/{component_id}` | GET | Get specific component info |
|
||||||
|
| `/v1/models` | GET | List all available models |
|
||||||
|
| `/v1/functions` | GET | List all available functions/endpoints |
|
||||||
|
| `/v1/status` | GET | Aggregated health status |
|
||||||
|
| `/v1/openapi` | GET | Aggregated OpenAPI 3.1.0 spec (all components) |
|
||||||
|
| `/v1/docs` | GET | Swagger UI for aggregated API |
|
||||||
|
| `/v1/redoc` | GET | ReDoc for aggregated API |
|
||||||
|
| `/v1/openapi/component/{component_id}` | GET | OpenAPI spec for a single component |
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Configure via environment variables (prefix: `CATALOG_`):
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `CATALOG_HOST` | `0.0.0.0` | Server host |
|
||||||
|
| `CATALOG_PORT` | `11000` | Server port (Production API Gateway: 11000) |
|
||||||
|
| `CATALOG_LOG_LEVEL` | `INFO` | Log level |
|
||||||
|
| `CATALOG_LLM_URL` | `http://didiAI-llm-api:14011` | LLM Inference API URL |
|
||||||
|
| `CATALOG_AUDIO_URL` | `http://didiAI-audio-api:54300` | Audio API URL |
|
||||||
|
| `CATALOG_VIDEO_URL` | `http://didiAI-video-api:54600` | Video Analysis API URL |
|
||||||
|
| `CATALOG_WEB_URL` | `http://didiAI-web-api:51100` | Web API URL |
|
||||||
|
| `CATALOG_COMPONENT_TIMEOUT` | `10` | Component request timeout (seconds) |
|
||||||
|
|
||||||
|
## Example Responses
|
||||||
|
|
||||||
|
### List Components
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:11000/v1/components | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"component_id": "llm-inference",
|
||||||
|
"base_url": "http://didiAI-llm-api:14011",
|
||||||
|
"resource": {
|
||||||
|
"name": "LLM Inference Gateway",
|
||||||
|
"slug": "llm-inference",
|
||||||
|
"resource_type": "api_service",
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"models": [...],
|
||||||
|
"functions": [...]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "audio-transcription",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 4,
|
||||||
|
"errors": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List All Models
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:11000/v1/models | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"models": [
|
||||||
|
{
|
||||||
|
"name": "Qwen3.5-35B-A3B",
|
||||||
|
"slug": "qwen3.5",
|
||||||
|
"provider": "vllm",
|
||||||
|
"model_type": "llm",
|
||||||
|
"component_id": "llm-inference",
|
||||||
|
"component_name": "LLM Inference Gateway",
|
||||||
|
...
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Whisper large-v3-turbo",
|
||||||
|
"component_id": "audio-transcription",
|
||||||
|
...
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component Status
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:11000/v1/status | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "healthy",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"component_id": "llm-inference",
|
||||||
|
"url": "http://didiAI-llm-api:14011",
|
||||||
|
"reachable": true,
|
||||||
|
"healthy": true,
|
||||||
|
"status_code": 200
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"component_id": "audio-transcription",
|
||||||
|
"reachable": true,
|
||||||
|
"healthy": true,
|
||||||
|
"status_code": 200
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"healthy_count": 4,
|
||||||
|
"total_count": 4
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### 1. Backend System Integration
|
||||||
|
|
||||||
|
Your backend can pull all service metadata and populate the database:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
# Pull all components
|
||||||
|
response = requests.get("http://localhost:11000/v1/components")
|
||||||
|
components = response.json()["components"]
|
||||||
|
|
||||||
|
for comp in components:
|
||||||
|
# Populate catalog.resources
|
||||||
|
db.insert_resource(comp["resource"])
|
||||||
|
|
||||||
|
# Populate catalog.models
|
||||||
|
for model in comp.get("models", []):
|
||||||
|
db.insert_model(model)
|
||||||
|
|
||||||
|
# Populate catalog.functions
|
||||||
|
for function in comp.get("functions", []):
|
||||||
|
db.insert_function(function)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Service Discovery
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Find all vision models
|
||||||
|
response = requests.get("http://localhost:11000/v1/models")
|
||||||
|
models = response.json()["models"]
|
||||||
|
|
||||||
|
vision_models = [m for m in models if m["model_type"] == "vision"]
|
||||||
|
print(f"Found {len(vision_models)} vision models")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Health Monitoring
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Check system health
|
||||||
|
response = requests.get("http://localhost:11000/v1/status")
|
||||||
|
status = response.json()
|
||||||
|
|
||||||
|
if status["status"] != "healthy":
|
||||||
|
alert(f"System degraded: {status['healthy_count']}/{status['total_count']} healthy")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
+-----------------------------------------------------+
|
||||||
|
| Catalog API (11000) |
|
||||||
|
| - Aggregates /v1/info from all components |
|
||||||
|
| - No database, just HTTP aggregation |
|
||||||
|
| - Read-only, no writes |
|
||||||
|
+----------------+------------------------------------+
|
||||||
|
|
|
||||||
|
+-----------+-----------+-----------+
|
||||||
|
v v v v
|
||||||
|
+--------+ +---------+ +--------+ +----------+
|
||||||
|
| LLM | | Audio | | Video | | Web |
|
||||||
|
| 14011 | | 54300 | | 54600 | | 51100 |
|
||||||
|
+--------+ +---------+ +--------+ +----------+
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install dependencies
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Run locally (requires components running)
|
||||||
|
uv run python -m uvicorn catalog_api.app:app --host 0.0.0.0 --port 11000
|
||||||
|
|
||||||
|
# Test
|
||||||
|
curl http://localhost:11000/v1/components | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies on Other Modules
|
||||||
|
|
||||||
|
This module aggregates information from:
|
||||||
|
- `llm-inference` (port 14011, Docker internal: didiAI-llm-api)
|
||||||
|
- `audio` (port 54300, Docker internal: didiAI-audio-api)
|
||||||
|
- `video-analysis` (port 54600, Docker internal: didiAI-video-api)
|
||||||
|
- `web` (port 51100, Docker internal: didiAI-web-api)
|
||||||
|
|
||||||
|
**Note:** The catalog API can function with partial availability. If a component is unavailable, it will be skipped with a warning in the logs.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
32
ai_platform/modules/catalog-api/deploy/.env.example
Normal file
32
ai_platform/modules/catalog-api/deploy/.env.example
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
# Catalog API Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# Required Configuration (no defaults)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# External URL for OpenAPI spec - REQUIRED
|
||||||
|
# This is the URL that external clients will use to access the APIs
|
||||||
|
CATALOG_EXTERNAL_URL=http://10.11.10.42
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Optional Configuration (has defaults)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
# CATALOG_HOST=0.0.0.0
|
||||||
|
# CATALOG_PORT=11000
|
||||||
|
# CATALOG_LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
# Component URLs (Docker internal network)
|
||||||
|
# CATALOG_LLM_URL=http://didiAI-llm-api:14011
|
||||||
|
# CATALOG_AUDIO_URL=http://didiAI-audio-api:54300
|
||||||
|
# CATALOG_VIDEO_URL=http://didiAI-video-api:54600
|
||||||
|
# CATALOG_WEB_URL=http://didiAI-web-api:51100
|
||||||
|
|
||||||
|
# External ports for OpenAPI spec (match your docker-compose port mappings)
|
||||||
|
# CATALOG_LLM_EXTERNAL_PORT=14011
|
||||||
|
# CATALOG_AUDIO_EXTERNAL_PORT=54300
|
||||||
|
# CATALOG_VIDEO_EXTERNAL_PORT=54600
|
||||||
|
# CATALOG_WEB_EXTERNAL_PORT=51100
|
||||||
|
|
||||||
|
# HTTP client settings
|
||||||
|
# CATALOG_COMPONENT_TIMEOUT=10
|
||||||
39
ai_platform/modules/catalog-api/deploy/Dockerfile
Normal file
39
ai_platform/modules/catalog-api/deploy/Dockerfile
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
# Catalog API - Dockerfile
|
||||||
|
FROM python:3.11.12-slim AS builder
|
||||||
|
|
||||||
|
# Install uv
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.10 /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy dependency files
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Install dependencies (no lockfile yet)
|
||||||
|
RUN uv sync --no-dev
|
||||||
|
|
||||||
|
# Final stage
|
||||||
|
FROM python:3.11.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy virtual environment from builder
|
||||||
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
COPY src/ ./src/
|
||||||
|
|
||||||
|
# Set environment
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
ENV CATALOG_HOST=0.0.0.0
|
||||||
|
ENV CATALOG_PORT=11000
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||||
|
CMD python -c "import urllib.request; import os; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"CATALOG_PORT\", 11000)}/health')" || exit 1
|
||||||
|
|
||||||
|
EXPOSE 11000
|
||||||
|
|
||||||
|
CMD python -m uvicorn catalog_api.app:app --host 0.0.0.0 --port ${CATALOG_PORT}
|
||||||
99
ai_platform/modules/catalog-api/deploy/deploy.sh
Normal file
99
ai_platform/modules/catalog-api/deploy/deploy.sh
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Docker Compose Startup Script for Catalog API
|
||||||
|
#
|
||||||
|
# Usage: ./deploy/deploy.sh [OPTIONS]
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --detach, -d Run in detached mode
|
||||||
|
# --down Stop and remove containers
|
||||||
|
# --logs Show logs
|
||||||
|
# --help, -h Show this help message
|
||||||
|
#
|
||||||
|
# Required: Set environment variables in .env file or export them before running.
|
||||||
|
# See .env.example for the full list of required variables.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Script directory
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
# Load .env file if it exists
|
||||||
|
ENV_FILE=""
|
||||||
|
if [[ -f "$SCRIPT_DIR/.env" ]]; then
|
||||||
|
ENV_FILE="$SCRIPT_DIR/.env"
|
||||||
|
elif [[ -f "$SCRIPT_DIR/../.env" ]]; then
|
||||||
|
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$ENV_FILE" ]]; then
|
||||||
|
echo "Loading environment from: $ENV_FILE"
|
||||||
|
set -a
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
DETACH=""
|
||||||
|
ACTION="up"
|
||||||
|
|
||||||
|
show_help() {
|
||||||
|
sed -n '2,16p' "$0" | sed 's/^# //' | sed 's/^#//'
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
check_required_var() {
|
||||||
|
local var_name="$1"
|
||||||
|
if [[ -z "${!var_name:-}" ]]; then
|
||||||
|
echo "ERROR: Required environment variable $var_name is not set"
|
||||||
|
echo "Set it in .env file or export it before running this script"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--detach|-d)
|
||||||
|
DETACH="-d"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--down)
|
||||||
|
ACTION="down"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--logs)
|
||||||
|
ACTION="logs"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--help|-h)
|
||||||
|
show_help
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Unknown option: $1"
|
||||||
|
echo "Use --help for usage information"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Check required variables
|
||||||
|
check_required_var "CATALOG_EXTERNAL_URL"
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
case $ACTION in
|
||||||
|
up)
|
||||||
|
echo "Starting Catalog API"
|
||||||
|
echo " External URL: $CATALOG_EXTERNAL_URL"
|
||||||
|
echo ""
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
exec docker compose up $DETACH
|
||||||
|
;;
|
||||||
|
down)
|
||||||
|
echo "Stopping Catalog API containers..."
|
||||||
|
exec docker compose down
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
exec docker compose logs -f
|
||||||
|
;;
|
||||||
|
esac
|
||||||
64
ai_platform/modules/catalog-api/deploy/docker-compose.yml
Normal file
64
ai_platform/modules/catalog-api/deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
# Catalog API - Docker Compose Configuration
|
||||||
|
#
|
||||||
|
# Port Allocation (Production API Gateway: 11000):
|
||||||
|
# 11000 - Catalog API (main orchestrator gateway)
|
||||||
|
#
|
||||||
|
# Network:
|
||||||
|
# Uses deploy_default network (shared with other modules)
|
||||||
|
#
|
||||||
|
# Naming Convention: didiAI-{module}-{service}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
didi-network:
|
||||||
|
external: true # single shared network for all DIDI + AI platform stacks
|
||||||
|
|
||||||
|
services:
|
||||||
|
catalog-api:
|
||||||
|
container_name: didiAI-catalog-api
|
||||||
|
image: didiai-catalog-api
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile
|
||||||
|
|
||||||
|
# No external port - accessible only via Gateway (:11000/catalog/)
|
||||||
|
expose:
|
||||||
|
- "11000"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- didi-network
|
||||||
|
|
||||||
|
environment:
|
||||||
|
# Server settings
|
||||||
|
- CATALOG_HOST=0.0.0.0
|
||||||
|
- CATALOG_PORT=11000
|
||||||
|
- CATALOG_LOG_LEVEL=${CATALOG_LOG_LEVEL:-INFO}
|
||||||
|
|
||||||
|
# External URL for OpenAPI spec (REQUIRED - no default)
|
||||||
|
- CATALOG_EXTERNAL_URL=${CATALOG_EXTERNAL_URL}
|
||||||
|
|
||||||
|
# Component URLs (Docker internal network)
|
||||||
|
- CATALOG_LLM_URL=${CATALOG_LLM_URL:-http://didiAI-llm-api:14011}
|
||||||
|
- CATALOG_AUDIO_URL=${CATALOG_AUDIO_URL:-http://didiAI-audio-api:54300}
|
||||||
|
- CATALOG_VIDEO_URL=${CATALOG_VIDEO_URL}
|
||||||
|
- CATALOG_WEB_URL=${CATALOG_WEB_URL:-http://didiAI-web-api:51100}
|
||||||
|
|
||||||
|
# External ports for OpenAPI spec (used when generating external URLs)
|
||||||
|
- CATALOG_LLM_EXTERNAL_PORT=${CATALOG_LLM_EXTERNAL_PORT:-14011}
|
||||||
|
- CATALOG_AUDIO_EXTERNAL_PORT=${CATALOG_AUDIO_EXTERNAL_PORT:-54300}
|
||||||
|
- CATALOG_VIDEO_EXTERNAL_PORT=${CATALOG_VIDEO_EXTERNAL_PORT:-54600}
|
||||||
|
- CATALOG_WEB_EXTERNAL_PORT=${CATALOG_WEB_EXTERNAL_PORT:-51100}
|
||||||
|
|
||||||
|
# HTTP client settings
|
||||||
|
- CATALOG_COMPONENT_TIMEOUT=${CATALOG_COMPONENT_TIMEOUT:-10}
|
||||||
|
|
||||||
|
# Runtime config polling
|
||||||
|
- CATALOG_DASHBOARD_URL=${CATALOG_DASHBOARD_URL:-http://didiAI-dashboard:51300}
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:11000/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
32
ai_platform/modules/catalog-api/pyproject.toml
Normal file
32
ai_platform/modules/catalog-api/pyproject.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
[project]
|
||||||
|
name = "catalog-api"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Service catalog API - aggregates component information"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.128.0",
|
||||||
|
"uvicorn[standard]>=0.30.0",
|
||||||
|
"httpx>=0.28.0",
|
||||||
|
"pydantic>=2.0",
|
||||||
|
"pydantic-settings>=2.0",
|
||||||
|
"prometheus-fastapi-instrumentator>=7.0.0",
|
||||||
|
"opentelemetry-instrumentation-fastapi>=0.50b0",
|
||||||
|
"opentelemetry-exporter-otlp-proto-grpc>=1.30.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-cov>=4.0",
|
||||||
|
"ruff>=0.8",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/catalog_api"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
extend = "../../ruff.toml"
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Catalog API - Service Registry & Discovery."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
730
ai_platform/modules/catalog-api/src/catalog_api/app.py
Normal file
730
ai_platform/modules/catalog-api/src/catalog_api/app.py
Normal file
|
|
@ -0,0 +1,730 @@
|
||||||
|
"""Catalog API - Service Registry & Discovery."""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
|
||||||
|
from .runtime_config import RuntimeConfigClient
|
||||||
|
from .settings import settings
|
||||||
|
|
||||||
|
# OpenAPI aggregation version
|
||||||
|
AGGREGATED_OPENAPI_VERSION = "0.1.0"
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, settings.log_level.upper()),
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Runtime config (reachable via app.state.runtime_config)
|
||||||
|
runtime_config = RuntimeConfigClient(
|
||||||
|
dashboard_url=settings.dashboard_url,
|
||||||
|
live_log_logger_name="catalog_api",
|
||||||
|
live_log_key="catalog.log.level",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
"""App lifespan: start runtime config polling."""
|
||||||
|
logger.info("Starting Catalog API")
|
||||||
|
await runtime_config.start()
|
||||||
|
app.state.runtime_config = runtime_config
|
||||||
|
yield
|
||||||
|
logger.info("Shutting down Catalog API")
|
||||||
|
await runtime_config.stop()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Catalog API",
|
||||||
|
description="Service catalog and discovery - aggregates component information",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- observability
|
||||||
|
|
||||||
|
|
||||||
|
# Prometheus /metrics + OTel tracing (no-op if deps missing or OTEL endpoint unset)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
|
||||||
|
from prometheus_fastapi_instrumentator import Instrumentator as _Inst # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
_Inst(should_group_status_codes=True).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
|
||||||
|
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
import os as _os # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
_otel_ep = _os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||||
|
|
||||||
|
|
||||||
|
if _otel_ep:
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry import trace as _trace # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry.sdk.resources import Resource as _R # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry.sdk.trace import TracerProvider as _TP # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor as _BSP # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as _Exp # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as _FInst # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
_provider = _TP(resource=_R.create({"service.name": _os.environ.get("OTEL_SERVICE_NAME", "didiAI-catalog-api")}))
|
||||||
|
|
||||||
|
|
||||||
|
_provider.add_span_processor(_BSP(_Exp(endpoint=_otel_ep, insecure=True)))
|
||||||
|
|
||||||
|
|
||||||
|
_trace.set_tracer_provider(_provider)
|
||||||
|
|
||||||
|
|
||||||
|
_FInst.instrument_app(app)
|
||||||
|
|
||||||
|
|
||||||
|
print(f"[otel] didiAI-catalog-api instrumented -> {_otel_ep}")
|
||||||
|
|
||||||
|
|
||||||
|
except ImportError as _e:
|
||||||
|
|
||||||
|
|
||||||
|
print(f"[otel] skip: {_e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
"""Health check endpoint."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/components")
|
||||||
|
async def list_components() -> JSONResponse:
|
||||||
|
"""
|
||||||
|
List all registered components with their metadata.
|
||||||
|
|
||||||
|
Aggregates /v1/info from all configured components and returns
|
||||||
|
a unified response containing:
|
||||||
|
- Component resource information
|
||||||
|
- Available models
|
||||||
|
- Available functions/endpoints
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: List of components with full metadata
|
||||||
|
"""
|
||||||
|
components = []
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for component in settings.get_components():
|
||||||
|
try:
|
||||||
|
logger.info(f"Fetching info from {component.id} at {component.url}/v1/info")
|
||||||
|
response = await client.get(
|
||||||
|
f"{component.url}/v1/info",
|
||||||
|
timeout=component.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Add component ID to the response
|
||||||
|
data["component_id"] = component.id
|
||||||
|
data["base_url"] = component.url
|
||||||
|
|
||||||
|
components.append(data)
|
||||||
|
logger.info(f"✓ Successfully fetched info from {component.id}")
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
error_msg = f"Timeout fetching {component.id}"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
errors.append({"component_id": component.id, "error": "timeout", "url": component.url})
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_msg = f"HTTP {e.response.status_code} from {component.id}"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
errors.append({
|
||||||
|
"component_id": component.id,
|
||||||
|
"error": f"http_{e.response.status_code}",
|
||||||
|
"url": component.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error fetching {component.id}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
errors.append({
|
||||||
|
"component_id": component.id,
|
||||||
|
"error": str(e),
|
||||||
|
"url": component.url,
|
||||||
|
})
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"components": components,
|
||||||
|
"total": len(components),
|
||||||
|
"errors": errors if errors else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/components/{component_id}")
|
||||||
|
async def get_component(component_id: str) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Get information for a specific component.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
component_id: Component identifier (e.g., "llm-inference", "audio-transcription")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: Component metadata
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If component not found or unreachable
|
||||||
|
"""
|
||||||
|
# Find component configuration
|
||||||
|
component_config = None
|
||||||
|
for comp in settings.get_components():
|
||||||
|
if comp.id == component_id:
|
||||||
|
component_config = comp
|
||||||
|
break
|
||||||
|
|
||||||
|
if not component_config:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Component '{component_id}' not found. Available: {[c.id for c in settings.get_components()]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch component info
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{component_config.url}/v1/info",
|
||||||
|
timeout=component_config.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Add component ID
|
||||||
|
data["component_id"] = component_id
|
||||||
|
data["base_url"] = component_config.url
|
||||||
|
|
||||||
|
return JSONResponse(content=data)
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=504,
|
||||||
|
detail=f"Timeout fetching {component_id} from {component_config.url}",
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"HTTP error from {component_id}: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error fetching {component_id}: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/models")
|
||||||
|
async def list_models() -> JSONResponse:
|
||||||
|
"""
|
||||||
|
List all available models across all components.
|
||||||
|
|
||||||
|
Extracts model information from all components and returns
|
||||||
|
a unified list with component attribution.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: List of all models with metadata
|
||||||
|
"""
|
||||||
|
all_models = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for component in settings.get_components():
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
f"{component.url}/v1/info",
|
||||||
|
timeout=component.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Extract models and add component context
|
||||||
|
for model in data.get("models", []):
|
||||||
|
model["component_id"] = component.id
|
||||||
|
model["component_name"] = data.get("resource", {}).get("name", component.id)
|
||||||
|
all_models.append(model)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Skipping {component.id} due to error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"models": all_models,
|
||||||
|
"total": len(all_models),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/functions")
|
||||||
|
async def list_functions() -> JSONResponse:
|
||||||
|
"""
|
||||||
|
List all available functions/endpoints across all components.
|
||||||
|
|
||||||
|
Extracts function information from all components and returns
|
||||||
|
a unified list with component attribution.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: List of all functions with metadata
|
||||||
|
"""
|
||||||
|
all_functions = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for component in settings.get_components():
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
f"{component.url}/v1/info",
|
||||||
|
timeout=component.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Extract functions and add component context
|
||||||
|
for function in data.get("functions", []):
|
||||||
|
function["component_id"] = component.id
|
||||||
|
function["component_name"] = data.get("resource", {}).get("name", component.id)
|
||||||
|
all_functions.append(function)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Skipping {component.id} due to error: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"functions": all_functions,
|
||||||
|
"total": len(all_functions),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/status")
|
||||||
|
async def get_status() -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Get aggregated status of all components.
|
||||||
|
|
||||||
|
Checks health/connectivity of all registered components
|
||||||
|
and returns their status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: Status summary for all components
|
||||||
|
"""
|
||||||
|
component_status = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for component in settings.get_components():
|
||||||
|
status_entry = {
|
||||||
|
"component_id": component.id,
|
||||||
|
"url": component.url,
|
||||||
|
"reachable": False,
|
||||||
|
"healthy": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to fetch /v1/info
|
||||||
|
response = await client.get(
|
||||||
|
f"{component.url}/v1/info",
|
||||||
|
timeout=component.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
status_entry["reachable"] = True
|
||||||
|
status_entry["healthy"] = True
|
||||||
|
status_entry["status_code"] = response.status_code
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
status_entry["error"] = "timeout"
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
status_entry["reachable"] = True
|
||||||
|
status_entry["status_code"] = e.response.status_code
|
||||||
|
status_entry["error"] = f"http_{e.response.status_code}"
|
||||||
|
except Exception as e:
|
||||||
|
status_entry["error"] = str(e)
|
||||||
|
|
||||||
|
component_status.append(status_entry)
|
||||||
|
|
||||||
|
# Determine overall status
|
||||||
|
healthy_count = sum(1 for s in component_status if s["healthy"])
|
||||||
|
total_count = len(component_status)
|
||||||
|
|
||||||
|
if healthy_count == total_count:
|
||||||
|
overall_status = "healthy"
|
||||||
|
elif healthy_count > 0:
|
||||||
|
overall_status = "degraded"
|
||||||
|
else:
|
||||||
|
overall_status = "unhealthy"
|
||||||
|
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"status": overall_status,
|
||||||
|
"components": component_status,
|
||||||
|
"healthy_count": healthy_count,
|
||||||
|
"total_count": total_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_openapi_schemas(
|
||||||
|
base_spec: dict[str, Any],
|
||||||
|
component_spec: dict[str, Any],
|
||||||
|
component_id: str,
|
||||||
|
component_url: str,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Merge a component's OpenAPI spec into the base aggregated spec.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_spec: The aggregated OpenAPI spec to merge into (modified in place).
|
||||||
|
component_spec: The component's OpenAPI spec to merge.
|
||||||
|
component_id: Component identifier for prefixing paths.
|
||||||
|
component_url: Component's base URL for server info.
|
||||||
|
"""
|
||||||
|
# Merge paths with component prefix
|
||||||
|
component_paths = component_spec.get("paths", {})
|
||||||
|
for path, path_item in component_paths.items():
|
||||||
|
# Prefix path with component ID to avoid collisions
|
||||||
|
prefixed_path = f"/{component_id}{path}"
|
||||||
|
|
||||||
|
# Deep copy to avoid modifying original
|
||||||
|
new_path_item = copy.deepcopy(path_item)
|
||||||
|
|
||||||
|
# Add component tag to all operations
|
||||||
|
for method in ["get", "post", "put", "delete", "patch", "options", "head"]:
|
||||||
|
if method in new_path_item:
|
||||||
|
operation = new_path_item[method]
|
||||||
|
# Add component as a tag
|
||||||
|
existing_tags = operation.get("tags", [])
|
||||||
|
if component_id not in existing_tags:
|
||||||
|
operation["tags"] = [component_id] + existing_tags
|
||||||
|
# Update operationId to be unique
|
||||||
|
if "operationId" in operation:
|
||||||
|
operation["operationId"] = f"{component_id}_{operation['operationId']}"
|
||||||
|
# Add server override for this path
|
||||||
|
operation["servers"] = [{"url": component_url}]
|
||||||
|
|
||||||
|
base_spec["paths"][prefixed_path] = new_path_item
|
||||||
|
|
||||||
|
# Merge components/schemas with component prefix
|
||||||
|
component_schemas = component_spec.get("components", {}).get("schemas", {})
|
||||||
|
if "components" not in base_spec:
|
||||||
|
base_spec["components"] = {}
|
||||||
|
if "schemas" not in base_spec["components"]:
|
||||||
|
base_spec["components"]["schemas"] = {}
|
||||||
|
|
||||||
|
for schema_name, schema_def in component_schemas.items():
|
||||||
|
# Prefix schema name to avoid collisions
|
||||||
|
prefixed_name = f"{component_id}_{schema_name}"
|
||||||
|
base_spec["components"]["schemas"][prefixed_name] = copy.deepcopy(schema_def)
|
||||||
|
|
||||||
|
# Update $ref references in the schema
|
||||||
|
_update_refs(base_spec["components"]["schemas"][prefixed_name], component_id)
|
||||||
|
|
||||||
|
# Update $refs in paths to use prefixed schema names (only for this component's paths)
|
||||||
|
for path, path_item in base_spec["paths"].items():
|
||||||
|
if path.startswith(f"/{component_id}"):
|
||||||
|
for method_item in path_item.values():
|
||||||
|
if isinstance(method_item, dict):
|
||||||
|
_update_refs(method_item, component_id)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_refs(obj: Any, component_id: str) -> None:
|
||||||
|
"""
|
||||||
|
Recursively update $ref references to use component-prefixed schema names.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
obj: Object to update (modified in place).
|
||||||
|
component_id: Component identifier for prefixing.
|
||||||
|
"""
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
for key, value in obj.items():
|
||||||
|
if key == "$ref" and isinstance(value, str):
|
||||||
|
# Update reference: #/components/schemas/Name -> #/components/schemas/component_Name
|
||||||
|
if value.startswith("#/components/schemas/"):
|
||||||
|
schema_name = value.split("/")[-1]
|
||||||
|
obj[key] = f"#/components/schemas/{component_id}_{schema_name}"
|
||||||
|
else:
|
||||||
|
_update_refs(value, component_id)
|
||||||
|
elif isinstance(obj, list):
|
||||||
|
for item in obj:
|
||||||
|
_update_refs(item, component_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/openapi")
|
||||||
|
async def get_aggregated_openapi() -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Get aggregated OpenAPI specification from all components.
|
||||||
|
|
||||||
|
Fetches /openapi.json from each registered component and merges them
|
||||||
|
into a single unified OpenAPI 3.x specification.
|
||||||
|
|
||||||
|
Features:
|
||||||
|
- Paths are prefixed with component ID (e.g., /llm-inference/v1/models)
|
||||||
|
- Schemas are prefixed to avoid naming collisions
|
||||||
|
- Each operation includes server override pointing to the actual component
|
||||||
|
- Components are organized by tags
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: Aggregated OpenAPI 3.1.0 specification
|
||||||
|
"""
|
||||||
|
# Base aggregated spec
|
||||||
|
aggregated_spec: dict[str, Any] = {
|
||||||
|
"openapi": "3.1.0",
|
||||||
|
"info": {
|
||||||
|
"title": "didiAI - Aggregated ML Services API",
|
||||||
|
"description": (
|
||||||
|
"Unified OpenAPI specification aggregating all ML service endpoints.\n\n"
|
||||||
|
"## Components\n"
|
||||||
|
"- **llm-inference**: LLM inference gateway (chat completions, models)\n"
|
||||||
|
"- **audio-transcription**: Speech-to-text transcription\n"
|
||||||
|
"- **video-analysis**: Deepfake detection and semantic analysis\n"
|
||||||
|
"- **web-factcheck**: Web search and evidence gathering\n"
|
||||||
|
),
|
||||||
|
"version": AGGREGATED_OPENAPI_VERSION,
|
||||||
|
"contact": {"name": "didiAI Team"},
|
||||||
|
},
|
||||||
|
"servers": [
|
||||||
|
{"url": f"{settings.external_url}:{settings.port}", "description": "Catalog API (aggregator)"},
|
||||||
|
],
|
||||||
|
"paths": {},
|
||||||
|
"components": {"schemas": {}},
|
||||||
|
"tags": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
errors = []
|
||||||
|
successful_components = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
for component in settings.get_components():
|
||||||
|
try:
|
||||||
|
logger.info(f"Fetching OpenAPI from {component.id} at {component.url}/openapi.json")
|
||||||
|
response = await client.get(
|
||||||
|
f"{component.url}/openapi.json",
|
||||||
|
timeout=component.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
component_spec = response.json()
|
||||||
|
|
||||||
|
# Add component as a tag
|
||||||
|
component_info = component_spec.get("info", {})
|
||||||
|
aggregated_spec["tags"].append({
|
||||||
|
"name": component.id,
|
||||||
|
"description": component_info.get("description", f"{component.id} API"),
|
||||||
|
"externalDocs": {"url": f"{component.external_url}/docs"},
|
||||||
|
})
|
||||||
|
|
||||||
|
# Merge component spec into aggregated (use external URL for OpenAPI)
|
||||||
|
_merge_openapi_schemas(
|
||||||
|
aggregated_spec,
|
||||||
|
component_spec,
|
||||||
|
component.id,
|
||||||
|
component.external_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
successful_components.append(component.id)
|
||||||
|
logger.info(f"✓ Successfully merged OpenAPI from {component.id}")
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
error_msg = f"Timeout fetching OpenAPI from {component.id}"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
errors.append({"component_id": component.id, "error": "timeout"})
|
||||||
|
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_msg = f"HTTP {e.response.status_code} from {component.id}"
|
||||||
|
logger.warning(error_msg)
|
||||||
|
errors.append({"component_id": component.id, "error": f"http_{e.response.status_code}"})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = f"Error fetching OpenAPI from {component.id}: {str(e)}"
|
||||||
|
logger.error(error_msg)
|
||||||
|
errors.append({"component_id": component.id, "error": str(e)})
|
||||||
|
|
||||||
|
# Add metadata about aggregation
|
||||||
|
aggregated_spec["info"]["x-aggregation"] = {
|
||||||
|
"components_included": successful_components,
|
||||||
|
"components_failed": [e["component_id"] for e in errors],
|
||||||
|
"errors": errors if errors else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSONResponse(content=aggregated_spec)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/docs", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def get_aggregated_swagger_ui() -> HTMLResponse:
|
||||||
|
"""
|
||||||
|
Swagger UI for the aggregated OpenAPI specification.
|
||||||
|
|
||||||
|
Provides an interactive documentation interface for all ML services.
|
||||||
|
"""
|
||||||
|
html_content = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>didiAI - API Documentation</title>
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||||
|
<style>
|
||||||
|
body {{ margin: 0; padding: 0; }}
|
||||||
|
.swagger-ui .topbar {{ display: none; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||||
|
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = function() {{
|
||||||
|
SwaggerUIBundle({{
|
||||||
|
url: "/v1/openapi",
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
presets: [
|
||||||
|
SwaggerUIBundle.presets.apis,
|
||||||
|
SwaggerUIStandalonePreset
|
||||||
|
],
|
||||||
|
layout: "StandaloneLayout",
|
||||||
|
plugins: [
|
||||||
|
SwaggerUIBundle.plugins.DownloadUrl
|
||||||
|
],
|
||||||
|
deepLinking: true,
|
||||||
|
showExtensions: true,
|
||||||
|
showCommonExtensions: true,
|
||||||
|
filter: true,
|
||||||
|
tagsSorter: "alpha",
|
||||||
|
operationsSorter: "alpha"
|
||||||
|
}});
|
||||||
|
}};
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return HTMLResponse(content=html_content)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/redoc", response_class=HTMLResponse, include_in_schema=False)
|
||||||
|
async def get_aggregated_redoc() -> HTMLResponse:
|
||||||
|
"""
|
||||||
|
ReDoc UI for the aggregated OpenAPI specification.
|
||||||
|
|
||||||
|
Provides a clean, readable documentation interface for all ML services.
|
||||||
|
"""
|
||||||
|
html_content = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>didiAI - API Documentation</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body { margin: 0; padding: 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<redoc spec-url='/v1/openapi'></redoc>
|
||||||
|
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
return HTMLResponse(content=html_content)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/v1/openapi/component/{component_id}")
|
||||||
|
async def get_component_openapi(component_id: str) -> JSONResponse:
|
||||||
|
"""
|
||||||
|
Get OpenAPI specification for a specific component.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
component_id: Component identifier (e.g., "llm-inference", "audio-transcription")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSONResponse: Component's OpenAPI specification
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: If component not found or unreachable
|
||||||
|
"""
|
||||||
|
# Find component configuration
|
||||||
|
component_config = None
|
||||||
|
for comp in settings.get_components():
|
||||||
|
if comp.id == component_id:
|
||||||
|
component_config = comp
|
||||||
|
break
|
||||||
|
|
||||||
|
if not component_config:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"Component '{component_id}' not found. Available: {[c.id for c in settings.get_components()]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(
|
||||||
|
f"{component_config.url}/openapi.json",
|
||||||
|
timeout=component_config.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return JSONResponse(content=response.json())
|
||||||
|
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=504,
|
||||||
|
detail=f"Timeout fetching OpenAPI from {component_id}",
|
||||||
|
)
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
detail=f"HTTP error from {component_id}: {e.response.text}",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Error fetching OpenAPI from {component_id}: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"catalog_api.app:app",
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
log_level=settings.log_level.lower(),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,157 @@
|
||||||
|
"""Runtime config client — fetches config overrides from dashboard.
|
||||||
|
|
||||||
|
Polls the dashboard /api/config endpoint periodically and caches values
|
||||||
|
in memory. All accessors fall back to caller-supplied defaults if the
|
||||||
|
dashboard is unreachable or the key is missing.
|
||||||
|
|
||||||
|
Single source of truth = dashboard `KNOWN_KEYS`. This client carries no
|
||||||
|
local default registry; consumers pass their own fallback (typically the
|
||||||
|
pydantic settings value).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RuntimeConfigClient:
|
||||||
|
"""Polls dashboard /api/config and caches values in-process."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
dashboard_url: str | None,
|
||||||
|
poll_interval_seconds: int = 30,
|
||||||
|
request_timeout: float = 5.0,
|
||||||
|
live_log_logger_name: str | None = None,
|
||||||
|
live_log_key: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.dashboard_url = dashboard_url.rstrip("/") if dashboard_url else None
|
||||||
|
self.poll_interval = poll_interval_seconds
|
||||||
|
self._timeout = request_timeout
|
||||||
|
self._cache: dict[str, Any] = {}
|
||||||
|
self._client: httpx.AsyncClient | None = None
|
||||||
|
self._task: asyncio.Task[None] | None = None
|
||||||
|
self._enabled = bool(dashboard_url)
|
||||||
|
# Optional auto-apply: re-set log level on the named logger when the
|
||||||
|
# configured key changes value (e.g., `llm.log.level`).
|
||||||
|
self._live_log_logger_name = live_log_logger_name
|
||||||
|
self._live_log_key = live_log_key
|
||||||
|
self._last_log_level: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
def get(self, key: str, default: Any = None) -> Any:
|
||||||
|
v = self._cache.get(key)
|
||||||
|
return v if v is not None else default
|
||||||
|
|
||||||
|
def get_bool(self, key: str, default: bool = False) -> bool:
|
||||||
|
v = self._cache.get(key)
|
||||||
|
return bool(v) if v is not None else default
|
||||||
|
|
||||||
|
def get_int(self, key: str, default: int = 0) -> int:
|
||||||
|
v = self._cache.get(key)
|
||||||
|
if v is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return int(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_float(self, key: str, default: float = 0.0) -> float:
|
||||||
|
v = self._cache.get(key)
|
||||||
|
if v is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
def get_str(self, key: str, default: str = "") -> str:
|
||||||
|
v = self._cache.get(key)
|
||||||
|
return str(v) if v is not None else default
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
if not self._enabled:
|
||||||
|
logger.info("RuntimeConfigClient disabled (no dashboard URL)")
|
||||||
|
return
|
||||||
|
self._client = httpx.AsyncClient(
|
||||||
|
timeout=httpx.Timeout(
|
||||||
|
connect=2.0, read=self._timeout, write=2.0, pool=5.0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Fetch once synchronously so first requests already have overrides
|
||||||
|
await self._refresh()
|
||||||
|
self._task = asyncio.create_task(self._loop())
|
||||||
|
logger.info(
|
||||||
|
"RuntimeConfigClient started (polling %s every %ds, %d keys cached)",
|
||||||
|
self.dashboard_url,
|
||||||
|
self.poll_interval,
|
||||||
|
len(self._cache),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self._task is not None:
|
||||||
|
self._task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||||
|
await self._task
|
||||||
|
self._task = None
|
||||||
|
if self._client is not None and not self._client.is_closed:
|
||||||
|
await self._client.aclose()
|
||||||
|
self._client = None
|
||||||
|
|
||||||
|
async def _loop(self) -> None:
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self.poll_interval)
|
||||||
|
await self._refresh()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Config poll failed: %s", e)
|
||||||
|
|
||||||
|
async def _refresh(self) -> None:
|
||||||
|
if self._client is None or self.dashboard_url is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
resp = await self._client.get(f"{self.dashboard_url}/api/config")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Config refresh failed: %s", e)
|
||||||
|
return
|
||||||
|
|
||||||
|
items = data.get("items", {})
|
||||||
|
new_cache: dict[str, Any] = {}
|
||||||
|
for key, entry in items.items():
|
||||||
|
new_cache[key] = entry.get("value")
|
||||||
|
self._cache = new_cache
|
||||||
|
self._maybe_apply_log_level()
|
||||||
|
|
||||||
|
def _maybe_apply_log_level(self) -> None:
|
||||||
|
"""Re-apply log level live if configured key changed."""
|
||||||
|
if not self._live_log_key or not self._live_log_logger_name:
|
||||||
|
return
|
||||||
|
new_level = self.get_str(self._live_log_key)
|
||||||
|
if not new_level:
|
||||||
|
return
|
||||||
|
if new_level == self._last_log_level:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
level_int = logging.getLevelName(new_level.upper())
|
||||||
|
if isinstance(level_int, int):
|
||||||
|
logging.getLogger(self._live_log_logger_name).setLevel(level_int)
|
||||||
|
self._last_log_level = new_level
|
||||||
|
logger.info(
|
||||||
|
"Log level for %s changed to %s (via runtime config)",
|
||||||
|
self._live_log_logger_name,
|
||||||
|
new_level,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to apply log level %s: %s", new_level, e)
|
||||||
93
ai_platform/modules/catalog-api/src/catalog_api/settings.py
Normal file
93
ai_platform/modules/catalog-api/src/catalog_api/settings.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""Catalog API settings."""
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Component(BaseSettings):
|
||||||
|
"""Component configuration."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
url: str # Internal URL (Docker network)
|
||||||
|
external_url: str # External URL for OpenAPI spec
|
||||||
|
timeout: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogSettings(BaseSettings):
|
||||||
|
"""Catalog API configuration."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="CATALOG_",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Server settings
|
||||||
|
host: str = Field(default="0.0.0.0")
|
||||||
|
port: int = Field(default=11000, description="Port (Catalog API: 11000)")
|
||||||
|
log_level: str = Field(default="INFO")
|
||||||
|
|
||||||
|
# External URL for OpenAPI spec (required for external access)
|
||||||
|
external_url: str = Field(
|
||||||
|
description="External base URL for API (e.g., http://10.11.10.42). Required, no default.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Components to aggregate (configured via docker network - internal API ports)
|
||||||
|
llm_url: str = Field(
|
||||||
|
default="http://didiAI-llm-api:14011",
|
||||||
|
description="LLM Inference API URL (internal)",
|
||||||
|
)
|
||||||
|
audio_url: str = Field(
|
||||||
|
default="http://didiAI-audio-api:54300",
|
||||||
|
description="Audio API URL (internal)",
|
||||||
|
)
|
||||||
|
video_url: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Video Analysis API URL (internal, empty to disable)",
|
||||||
|
)
|
||||||
|
web_url: str = Field(
|
||||||
|
default="http://didiAI-web-api:51100",
|
||||||
|
description="Web API URL (internal)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# External ports for components (used in OpenAPI spec)
|
||||||
|
llm_external_port: int = Field(default=14011, description="LLM API external port")
|
||||||
|
audio_external_port: int = Field(default=54300, description="Audio API external port")
|
||||||
|
video_external_port: int = Field(default=54600, description="Video API external port")
|
||||||
|
web_external_port: int = Field(default=51100, description="Web API external port")
|
||||||
|
|
||||||
|
# HTTP client settings
|
||||||
|
component_timeout: int = Field(
|
||||||
|
default=10,
|
||||||
|
description="Timeout for component requests (seconds)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Runtime config (dashboard polling)
|
||||||
|
dashboard_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Optional dashboard base URL. When set, runtime_config polls "
|
||||||
|
"/api/config every 30s for live overrides."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_components(self) -> list[Component]:
|
||||||
|
"""Get list of configured components. Empty URLs are skipped."""
|
||||||
|
all_components = [
|
||||||
|
("llm-inference", self.llm_url, self.llm_external_port),
|
||||||
|
("audio-transcription", self.audio_url, self.audio_external_port),
|
||||||
|
("video-analysis", self.video_url, self.video_external_port),
|
||||||
|
("web-factcheck", self.web_url, self.web_external_port),
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
Component(
|
||||||
|
id=cid,
|
||||||
|
url=url,
|
||||||
|
external_url=f"{self.external_url}:{port}",
|
||||||
|
timeout=self.component_timeout,
|
||||||
|
)
|
||||||
|
for cid, url, port in all_components
|
||||||
|
if url
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
settings = CatalogSettings()
|
||||||
56
ai_platform/modules/cloak/INDEX.md
Normal file
56
ai_platform/modules/cloak/INDEX.md
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
# cloak — module index
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
HTTP wrapper around CloakBrowser. Scrapes Google / Bing / DDG SERPs and exposes
|
||||||
|
the parsed organic results as JSON. Used as the third-tier fallback for the AI
|
||||||
|
platform `web` module when SearXNG + paid rotation return thin results.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```
|
||||||
|
cloak/
|
||||||
|
├── pyproject.toml FastAPI + uvicorn + cloakbrowser deps
|
||||||
|
├── README.md User-facing docs
|
||||||
|
├── INDEX.md This file (module map)
|
||||||
|
├── src/cloak/
|
||||||
|
│ ├── __init__.py
|
||||||
|
│ ├── config.py Env-driven pydantic settings (CLOAK_* prefix)
|
||||||
|
│ ├── schemas.py Request/Response/Stats pydantic models
|
||||||
|
│ ├── scraper.py Per-engine HTML scrapers (google/bing/ddg)
|
||||||
|
│ ├── browser_pool.py Bounded async pool of CloakBrowser instances
|
||||||
|
│ └── server.py FastAPI app — POST /v1/search, GET /health
|
||||||
|
├── tests/ pytest test suite
|
||||||
|
└── deploy/
|
||||||
|
├── Dockerfile FROM cloakhq/cloakbrowser:latest + FastAPI
|
||||||
|
└── docker-compose.yml didiAI-cloak on didi-network, port 8770
|
||||||
|
```
|
||||||
|
|
||||||
|
## External contracts
|
||||||
|
|
||||||
|
| Surface | Path | Method |
|
||||||
|
|---|---|---|
|
||||||
|
| Search | `/v1/search` | POST |
|
||||||
|
| Health | `/health` | GET |
|
||||||
|
|
||||||
|
Both reachable inside the cluster at `http://didiAI-cloak:8770/`. Host-port
|
||||||
|
`127.0.0.1:8770` is exposed only for local debugging on didi12.
|
||||||
|
|
||||||
|
## State
|
||||||
|
|
||||||
|
Stateless. No database, no Redis. The browser pool is in-process memory.
|
||||||
|
Cold-start (warm 3 browsers) ≈ 8–12 s; from then on each search is 2–4 s
|
||||||
|
end-to-end.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- `cloakhq/cloakbrowser:latest` Docker base image (bundles stealth Chromium + Xvfb)
|
||||||
|
- Outbound TCP to `google.com`, `bing.com`, `html.duckduckgo.com`
|
||||||
|
|
||||||
|
## Where it's consumed
|
||||||
|
|
||||||
|
- `ai_platform/modules/web/src/web/search/cloak.py` (CloakHTTPClient) — pending
|
||||||
|
- `ai_platform/modules/web/src/web/orchestrator.py::_run_search_stage` — pending tier-3 hook
|
||||||
|
|
||||||
|
Backend services (`agent-v3`, `didi-framework`, admin-dashboard) do NOT call
|
||||||
|
this service directly.
|
||||||
113
ai_platform/modules/cloak/README.md
Normal file
113
ai_platform/modules/cloak/README.md
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
# cloak — stealth-Chromium scraping service
|
||||||
|
|
||||||
|
Standalone HTTP service that scrapes Google / Bing / DuckDuckGo SERPs through a
|
||||||
|
warm pool of CloakBrowser (patched stealth Chromium) instances.
|
||||||
|
|
||||||
|
Designed as **tier-3 search fallback** for the AI platform `web` module: when
|
||||||
|
SearXNG + the paid rotation return thin results (often the case for very niche
|
||||||
|
or recent queries), `cloak` provides results scraped directly from Google,
|
||||||
|
Bing and DuckDuckGo's HTML SERPs.
|
||||||
|
|
||||||
|
## Why a separate service?
|
||||||
|
|
||||||
|
- **Isolated lifecycle.** Browser pool restarts don't take down the rest of the
|
||||||
|
AI platform.
|
||||||
|
- **Bounded footprint.** A small fixed pool (default 3 instances ≈ 1.2 GB RAM)
|
||||||
|
versus N pools spreading across every web worker.
|
||||||
|
- **Same deployment pattern** as `audio`, `embeddings`, `video-analysis` etc.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/search
|
||||||
|
{
|
||||||
|
"queries": ["BNR confiscare conturi 10000 euro"],
|
||||||
|
"engines": ["google", "bing", "ddg"],
|
||||||
|
"max_results_per_engine": 10,
|
||||||
|
"language": "ro" // optional hint
|
||||||
|
}
|
||||||
|
|
||||||
|
200 OK
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{"url": "...", "title": "...", "snippet": "...",
|
||||||
|
"engine": "google", "query": "...", "rank": 1},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"stats": [
|
||||||
|
{"engine": "google", "query": "...", "results_count": 10,
|
||||||
|
"blocked": false, "captcha": false, "elapsed_ms": 2750, "error": null},
|
||||||
|
...
|
||||||
|
],
|
||||||
|
"total_elapsed_ms": 2900
|
||||||
|
}
|
||||||
|
|
||||||
|
GET /health
|
||||||
|
{
|
||||||
|
"status": "healthy" | "degraded" | "unhealthy",
|
||||||
|
"pool_size": 3, "pool_available": 3, "version": "0.1.0"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auth is optional via `Authorization: Bearer <CLOAK_AUTH_TOKEN>`; when the env
|
||||||
|
var is unset (default), all requests are accepted (intra-cluster service —
|
||||||
|
should never be reachable from the internet).
|
||||||
|
|
||||||
|
## Configuration (env)
|
||||||
|
|
||||||
|
| Variable | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `CLOAK_HOST` | `0.0.0.0` | Bind address |
|
||||||
|
| `CLOAK_PORT` | `8770` | HTTP port |
|
||||||
|
| `CLOAK_POOL_SIZE` | `3` | Number of warm browsers (~400 MB each) |
|
||||||
|
| `CLOAK_SEARCH_TIMEOUT_SEC` | `20` | Hard timeout for entire `/v1/search` call |
|
||||||
|
| `CLOAK_PAGE_TIMEOUT_MS` | `18000` | Per-engine page load timeout |
|
||||||
|
| `CLOAK_MAX_ENGINES` | `3` | Cap on engines per request |
|
||||||
|
| `CLOAK_MAX_QUERIES` | `5` | Cap on queries per request |
|
||||||
|
| `CLOAK_DEFAULT_MAX_RESULTS` | `10` | Default per-engine result cap |
|
||||||
|
| `CLOAK_MAX_RESULTS_CAP` | `30` | Hard cap regardless of input |
|
||||||
|
| `CLOAK_ENGINE_MIN_INTERVAL_MS` | `200` | Throttle between successive scrapes per engine |
|
||||||
|
| `CLOAK_HUMANIZE` | `false` | Human-like mouse/keyboard timing (slower, better for behavioral anti-bot) |
|
||||||
|
| `CLOAK_AUTH_TOKEN` | `""` | Optional bearer token. Empty = no auth |
|
||||||
|
| `CLOAK_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ai_platform/modules/cloak/deploy
|
||||||
|
docker compose up -d --build
|
||||||
|
docker logs -f didiAI-cloak
|
||||||
|
# Health
|
||||||
|
curl -s http://127.0.0.1:8770/health | jq
|
||||||
|
# Smoke
|
||||||
|
curl -sS -X POST http://127.0.0.1:8770/v1/search \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"queries":["NYTimes climate report 2026"],"engines":["google","bing","ddg"]}' | jq '.stats'
|
||||||
|
```
|
||||||
|
|
||||||
|
## Where it fits
|
||||||
|
|
||||||
|
```
|
||||||
|
agent-v3 / claims-verifier
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
didiAI-web-api ──► SearXNG (free, local) [tier 1 — always]
|
||||||
|
+ Brave/Tavily/etc rotation [tier 2 — one paid per call]
|
||||||
|
+ cloak (this service) [tier 3 — only if tier 1+2 thin]
|
||||||
|
```
|
||||||
|
|
||||||
|
The web module's orchestrator decides when to invoke `cloak` based on the
|
||||||
|
number of unique results returned from tiers 1+2. agent-v3 and didi-framework
|
||||||
|
do not call `cloak` directly.
|
||||||
|
|
||||||
|
## Operational notes
|
||||||
|
|
||||||
|
- **Selectors break.** Google rotates its result-DOM classes every 6–12 months.
|
||||||
|
The scraper has fallback selectors but the primary path will eventually need
|
||||||
|
re-tuning. Monitor `stats.blocked` / `stats.results_count` over time.
|
||||||
|
- **Rate limits.** No formal limit on the SERP endpoints, but bursts trigger
|
||||||
|
captcha. Default `CLOAK_ENGINE_MIN_INTERVAL_MS=200` paces requests; tune up
|
||||||
|
if you see captcha rates rise.
|
||||||
|
- **CPU/RAM.** Each browser instance uses ~400 MB RAM and is single-CPU for
|
||||||
|
most of a page load. The default `CLOAK_POOL_SIZE=3` is sized for didi12 ≤ 5k
|
||||||
|
scrape ops/day; raise to 5–8 if pool starves the request queue.
|
||||||
22
ai_platform/modules/cloak/deploy/Dockerfile
Normal file
22
ai_platform/modules/cloak/deploy/Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
# cloak service — FastAPI wrapper around CloakBrowser
|
||||||
|
#
|
||||||
|
# Base image already has stealth Chromium, Xvfb, system fonts, Node 20.
|
||||||
|
# We just add the FastAPI app + uvicorn on top.
|
||||||
|
|
||||||
|
FROM cloakhq/cloakbrowser:latest
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
COPY src/ ./src/
|
||||||
|
RUN pip install --no-cache-dir \
|
||||||
|
"fastapi>=0.115.0" \
|
||||||
|
"uvicorn[standard]>=0.32.0" \
|
||||||
|
"pydantic>=2.0" \
|
||||||
|
"pydantic-settings>=2.0" \
|
||||||
|
&& pip install --no-cache-dir -e .
|
||||||
|
|
||||||
|
EXPOSE 8770
|
||||||
|
|
||||||
|
# Base image already runs Xvfb via /entrypoint.sh — we override the CMD only.
|
||||||
|
CMD ["python", "-m", "cloak.server"]
|
||||||
40
ai_platform/modules/cloak/deploy/docker-compose.yml
Normal file
40
ai_platform/modules/cloak/deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
services:
|
||||||
|
cloak:
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile
|
||||||
|
image: didi-ai/cloak:latest
|
||||||
|
container_name: didiAI-cloak
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- didi-network
|
||||||
|
environment:
|
||||||
|
CLOAK_HOST: 0.0.0.0
|
||||||
|
CLOAK_PORT: 8770
|
||||||
|
CLOAK_POOL_SIZE: ${CLOAK_POOL_SIZE:-3}
|
||||||
|
CLOAK_SEARCH_TIMEOUT_SEC: ${CLOAK_SEARCH_TIMEOUT_SEC:-20}
|
||||||
|
CLOAK_PAGE_TIMEOUT_MS: ${CLOAK_PAGE_TIMEOUT_MS:-18000}
|
||||||
|
CLOAK_MAX_ENGINES: ${CLOAK_MAX_ENGINES:-3}
|
||||||
|
CLOAK_MAX_QUERIES: ${CLOAK_MAX_QUERIES:-5}
|
||||||
|
CLOAK_DEFAULT_MAX_RESULTS: ${CLOAK_DEFAULT_MAX_RESULTS:-10}
|
||||||
|
CLOAK_MAX_RESULTS_CAP: ${CLOAK_MAX_RESULTS_CAP:-30}
|
||||||
|
CLOAK_ENGINE_MIN_INTERVAL_MS: ${CLOAK_ENGINE_MIN_INTERVAL_MS:-200}
|
||||||
|
CLOAK_HUMANIZE: ${CLOAK_HUMANIZE:-false}
|
||||||
|
CLOAK_AUTH_TOKEN: ${CLOAK_AUTH_TOKEN:-}
|
||||||
|
CLOAK_LOG_LEVEL: ${CLOAK_LOG_LEVEL:-INFO}
|
||||||
|
DISPLAY: ":99"
|
||||||
|
# Bind only to localhost on the host — intra-cluster access via DNS name
|
||||||
|
# `didiAI-cloak` on didi-network. Public exposure is undesirable (scraping
|
||||||
|
# endpoint should never be reachable from the internet).
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8770:8770"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; r=urllib.request.urlopen('http://127.0.0.1:8770/health', timeout=3); sys.exit(0 if r.status==200 else 1)\""]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
networks:
|
||||||
|
didi-network:
|
||||||
|
external: true
|
||||||
33
ai_platform/modules/cloak/pyproject.toml
Normal file
33
ai_platform/modules/cloak/pyproject.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
[project]
|
||||||
|
name = "cloak"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Stealth Chromium scraping service — Google/Bing/DDG search via CloakBrowser, exposed as HTTP API."
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.32.0",
|
||||||
|
"pydantic>=2.0",
|
||||||
|
"pydantic-settings>=2.0",
|
||||||
|
# cloakbrowser is provided by the base Docker image (cloakhq/cloakbrowser:latest).
|
||||||
|
# Listed in optional-dependencies for local dev only.
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0",
|
||||||
|
"pytest-asyncio>=0.24",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"ruff>=0.8",
|
||||||
|
"cloakbrowser",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/cloak"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
target-version = "py310"
|
||||||
3
ai_platform/modules/cloak/src/cloak/__init__.py
Normal file
3
ai_platform/modules/cloak/src/cloak/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Cloak — stealth scraping service for Google / Bing / DuckDuckGo search results."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
96
ai_platform/modules/cloak/src/cloak/browser_pool.py
Normal file
96
ai_platform/modules/cloak/src/cloak/browser_pool.py
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
"""Async pool of CloakBrowser instances kept warm.
|
||||||
|
|
||||||
|
The pool is a bounded asyncio.Queue of running Browser objects. acquire() and
|
||||||
|
release() check out an instance for the lifetime of one search call. If a
|
||||||
|
browser raises during use, we discard it and lazily replace it on the next
|
||||||
|
acquire so a single bad page doesn't permanently shrink the pool.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
logger = logging.getLogger("cloak.pool")
|
||||||
|
|
||||||
|
|
||||||
|
class BrowserPool:
|
||||||
|
"""Bounded pool of CloakBrowser instances."""
|
||||||
|
|
||||||
|
def __init__(self, size: int, *, humanize: bool):
|
||||||
|
self._size = size
|
||||||
|
self._humanize = humanize
|
||||||
|
self._queue: asyncio.Queue = asyncio.Queue(maxsize=size)
|
||||||
|
self._created = 0
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._closed = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def size(self) -> int:
|
||||||
|
return self._size
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available(self) -> int:
|
||||||
|
return self._queue.qsize()
|
||||||
|
|
||||||
|
async def _create(self):
|
||||||
|
"""Create one fresh browser. Imported lazily so tests can stub it."""
|
||||||
|
from cloakbrowser import launch_async # type: ignore
|
||||||
|
|
||||||
|
browser = await launch_async(headless=True, humanize=self._humanize)
|
||||||
|
self._created += 1
|
||||||
|
logger.info("BrowserPool: created instance %d/%d", self._created, self._size)
|
||||||
|
return browser
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Pre-create all instances up-front so first calls don't pay launch cost."""
|
||||||
|
async with self._lock:
|
||||||
|
for _ in range(self._size):
|
||||||
|
b = await self._create()
|
||||||
|
await self._queue.put(b)
|
||||||
|
logger.info("BrowserPool: warmed up with %d instances", self._size)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Close all browsers. Safe to call multiple times."""
|
||||||
|
self._closed = True
|
||||||
|
while not self._queue.empty():
|
||||||
|
try:
|
||||||
|
b = self._queue.get_nowait()
|
||||||
|
except asyncio.QueueEmpty:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
await b.close()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("BrowserPool: close failed: %s", e)
|
||||||
|
logger.info("BrowserPool: stopped")
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def acquire(self):
|
||||||
|
"""Check out one browser for the duration of the `async with` block.
|
||||||
|
|
||||||
|
If the browser dies during use (any exception inside the block), we
|
||||||
|
close it and replace with a fresh one on release.
|
||||||
|
"""
|
||||||
|
if self._closed:
|
||||||
|
raise RuntimeError("Pool is closed")
|
||||||
|
browser = await self._queue.get()
|
||||||
|
broken = False
|
||||||
|
try:
|
||||||
|
yield browser
|
||||||
|
except Exception:
|
||||||
|
broken = True
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if broken:
|
||||||
|
try:
|
||||||
|
await browser.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
fresh = await self._create()
|
||||||
|
await self._queue.put(fresh)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.error("BrowserPool: failed to replace dead browser: %s", e)
|
||||||
|
# Pool shrinks until next successful recreate
|
||||||
|
else:
|
||||||
|
await self._queue.put(browser)
|
||||||
50
ai_platform/modules/cloak/src/cloak/config.py
Normal file
50
ai_platform/modules/cloak/src/cloak/config.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""Cloak service configuration — env-driven via pydantic-settings."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class CloakSettings(BaseSettings):
|
||||||
|
"""Runtime configuration for the cloak service."""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(env_prefix="CLOAK_", case_sensitive=False)
|
||||||
|
|
||||||
|
# HTTP server
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 8770
|
||||||
|
|
||||||
|
# Browser pool — number of CloakBrowser instances kept warm.
|
||||||
|
# Each instance uses ~400 MB RAM. Default 3 keeps ~1.2 GB footprint
|
||||||
|
# which fits comfortably alongside the other AI platform services.
|
||||||
|
pool_size: int = 3
|
||||||
|
|
||||||
|
# Per-search timeout (entire search across engines).
|
||||||
|
search_timeout_sec: int = 20
|
||||||
|
|
||||||
|
# Per-engine page load timeout.
|
||||||
|
page_timeout_ms: int = 18000
|
||||||
|
|
||||||
|
# Max engines per call (cap to avoid abuse).
|
||||||
|
max_engines: int = 3
|
||||||
|
|
||||||
|
# Max queries per call (cap to avoid abuse).
|
||||||
|
max_queries: int = 5
|
||||||
|
|
||||||
|
# Default max results per (engine, query).
|
||||||
|
default_max_results: int = 10
|
||||||
|
max_results_cap: int = 30
|
||||||
|
|
||||||
|
# Throttling — minimum delay between successive scrapes on the same engine.
|
||||||
|
# Helps avoid tripping rate-limits when called in bursts.
|
||||||
|
engine_min_interval_ms: int = 200
|
||||||
|
|
||||||
|
# Humanize input (mouse/keyboard timing) — slows requests slightly but
|
||||||
|
# improves bot-detection scores. Default off for bulk throughput.
|
||||||
|
humanize: bool = False
|
||||||
|
|
||||||
|
# Optional shared bearer token. Empty = no auth (intra-cluster only).
|
||||||
|
auth_token: str = ""
|
||||||
|
|
||||||
|
# Log level
|
||||||
|
log_level: str = "INFO"
|
||||||
81
ai_platform/modules/cloak/src/cloak/schemas.py
Normal file
81
ai_platform/modules/cloak/src/cloak/schemas.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
"""Pydantic schemas — request / response contracts for the cloak service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
Engine = Literal["google", "bing", "ddg"]
|
||||||
|
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
"""One search call across one or more engines."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
queries: list[str] = Field(
|
||||||
|
...,
|
||||||
|
min_length=1,
|
||||||
|
description="One or more text queries; each is scraped on every engine in `engines`.",
|
||||||
|
)
|
||||||
|
engines: list[Engine] = Field(
|
||||||
|
default=["google", "bing", "ddg"],
|
||||||
|
description="Search engines to scrape. Order is independent (parallel execution).",
|
||||||
|
)
|
||||||
|
max_results_per_engine: int = Field(
|
||||||
|
default=10,
|
||||||
|
ge=1,
|
||||||
|
description="Max organic results returned per (engine, query).",
|
||||||
|
)
|
||||||
|
language: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Preferred language hint (e.g. 'en', 'ro'). Engine-specific behavior.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResult(BaseModel):
|
||||||
|
"""One organic search result."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
url: str
|
||||||
|
title: str
|
||||||
|
snippet: str = ""
|
||||||
|
engine: Engine
|
||||||
|
query: str
|
||||||
|
rank: int = Field(..., description="1-based rank within the engine's result list.")
|
||||||
|
|
||||||
|
|
||||||
|
class EngineStats(BaseModel):
|
||||||
|
"""Per-engine breakdown for diagnostics."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
engine: Engine
|
||||||
|
query: str
|
||||||
|
results_count: int
|
||||||
|
blocked: bool = False
|
||||||
|
captcha: bool = False
|
||||||
|
elapsed_ms: int
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SearchResponse(BaseModel):
|
||||||
|
"""Aggregate response across all (engine × query) pairs."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
results: list[SearchResult]
|
||||||
|
stats: list[EngineStats]
|
||||||
|
total_elapsed_ms: int
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
"""Health-probe payload."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
status: Literal["healthy", "degraded", "unhealthy"]
|
||||||
|
pool_size: int
|
||||||
|
pool_available: int
|
||||||
|
version: str
|
||||||
254
ai_platform/modules/cloak/src/cloak/scraper.py
Normal file
254
ai_platform/modules/cloak/src/cloak/scraper.py
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
"""HTML scrapers for Google / Bing / DuckDuckGo search result pages.
|
||||||
|
|
||||||
|
Each scraper accepts a Playwright Page (CloakBrowser-backed) and returns a
|
||||||
|
list of (url, title, snippet) tuples plus block/captcha flags. Selectors are
|
||||||
|
intentionally redundant — Google in particular rotates result-DOM classes
|
||||||
|
periodically. If both primary and fallback selectors fail, returns empty list.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import quote_plus, unquote, urlparse, parse_qs
|
||||||
|
|
||||||
|
logger = logging.getLogger("cloak.scraper")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ScrapeOutput:
|
||||||
|
results: list[tuple[str, str, str]] # (url, title, snippet)
|
||||||
|
blocked: bool = False
|
||||||
|
captcha: bool = False
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ─── URLs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def search_url(engine: str, query: str, language: str | None = None) -> str:
|
||||||
|
q = quote_plus(query)
|
||||||
|
if engine == "google":
|
||||||
|
hl = language or "en"
|
||||||
|
return f"https://www.google.com/search?q={q}&hl={hl}&num=20"
|
||||||
|
if engine == "bing":
|
||||||
|
return f"https://www.bing.com/search?q={q}&count=20"
|
||||||
|
if engine == "ddg":
|
||||||
|
# HTML endpoint is more scraper-friendly than the JS-driven SPA.
|
||||||
|
return f"https://html.duckduckgo.com/html/?q={q}"
|
||||||
|
raise ValueError(f"Unsupported engine: {engine}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Block / captcha detection ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _detect_block(html: str, title: str) -> tuple[bool, bool]:
|
||||||
|
"""Return (blocked, captcha) flags by inspecting page content."""
|
||||||
|
t = title.lower()
|
||||||
|
h = html.lower()
|
||||||
|
captcha_markers = [
|
||||||
|
"unusual traffic",
|
||||||
|
"before you continue to google search",
|
||||||
|
"/sorry/index",
|
||||||
|
"recaptcha",
|
||||||
|
"are you a robot",
|
||||||
|
]
|
||||||
|
block_markers = [
|
||||||
|
"access denied",
|
||||||
|
"<title>just a moment...</title>",
|
||||||
|
"checking your browser",
|
||||||
|
"blocked",
|
||||||
|
]
|
||||||
|
captcha = any(m in h for m in captcha_markers) or "sorry" in t
|
||||||
|
blocked = False
|
||||||
|
# Only flag block if we ALSO see no result containers (avoids false positives
|
||||||
|
# on pages that legitimately mention "blocked" in editorial content).
|
||||||
|
return blocked, captcha
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_google_redirect(href: str) -> str:
|
||||||
|
"""Google sometimes wraps result URLs in /url?q=...&sa=...; strip it."""
|
||||||
|
if href.startswith("/url?"):
|
||||||
|
try:
|
||||||
|
qs = parse_qs(urlparse("http://x" + href).query)
|
||||||
|
target = qs.get("q", [None])[0]
|
||||||
|
if target:
|
||||||
|
return unquote(target)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return href
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Per-engine scrapers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _scrape_google(page, max_results: int) -> ScrapeOutput:
|
||||||
|
"""Google organic results. Multiple selector strategies for resilience.
|
||||||
|
|
||||||
|
Captcha detection happens AFTER extraction: if we got 0 results AND markers
|
||||||
|
are present, it's a real captcha. The "before you continue" cookies banner
|
||||||
|
and "/sorry/index" footer links are present on every normal Google SERP,
|
||||||
|
so checking markers up front gives massive false-positives.
|
||||||
|
"""
|
||||||
|
selectors_to_try = [
|
||||||
|
# Modern (2025-2026) class names
|
||||||
|
"div.MjjYud:has(a h3)",
|
||||||
|
# Legacy: any div containing an h3 inside a link
|
||||||
|
"div.g a:has(h3)",
|
||||||
|
# Last resort
|
||||||
|
"a:has(h3)",
|
||||||
|
]
|
||||||
|
|
||||||
|
title = await page.title()
|
||||||
|
html = await page.content()
|
||||||
|
|
||||||
|
for sel in selectors_to_try:
|
||||||
|
try:
|
||||||
|
locator = page.locator(sel)
|
||||||
|
count = await locator.count()
|
||||||
|
if count == 0:
|
||||||
|
continue
|
||||||
|
results: list[tuple[str, str, str]] = []
|
||||||
|
for i in range(min(count, max_results * 2)):
|
||||||
|
el = locator.nth(i)
|
||||||
|
try:
|
||||||
|
# In modern Google each result has h3 inside an anchor.
|
||||||
|
a = el if (await el.evaluate("e => e.tagName")) == "A" else el.locator("a:has(h3)").first
|
||||||
|
href = await a.get_attribute("href")
|
||||||
|
if not href:
|
||||||
|
continue
|
||||||
|
href = _clean_google_redirect(href)
|
||||||
|
if not href.startswith("http"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
h3 = a.locator("h3").first
|
||||||
|
title_text = (await h3.inner_text()).strip()
|
||||||
|
except Exception:
|
||||||
|
title_text = (await a.inner_text()).strip()[:120]
|
||||||
|
if not title_text:
|
||||||
|
continue
|
||||||
|
# Snippet — best-effort, optional
|
||||||
|
snippet = ""
|
||||||
|
try:
|
||||||
|
# Look for any sibling or descendant span with text content.
|
||||||
|
snippet = await el.evaluate(
|
||||||
|
"e => { const t = e.innerText || ''; const lines = t.split('\\n'); return lines.slice(1, 4).join(' '); }"
|
||||||
|
)
|
||||||
|
snippet = (snippet or "").strip()[:300]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
results.append((href, title_text, snippet))
|
||||||
|
if len(results) >= max_results:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if results:
|
||||||
|
return ScrapeOutput(results=results)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Google selector '%s' failed: %s", sel, e)
|
||||||
|
|
||||||
|
# Zero results extracted → now check markers to distinguish captcha vs DOM rotation
|
||||||
|
_, captcha = _detect_block(html, title)
|
||||||
|
if captcha or "/sorry/index" in html or "unusual traffic" in html.lower():
|
||||||
|
return ScrapeOutput(results=[], captcha=True, error="captcha_detected_after_zero_results")
|
||||||
|
return ScrapeOutput(results=[], blocked=True, error="no_results_no_selectors_matched")
|
||||||
|
|
||||||
|
|
||||||
|
async def _scrape_bing(page, max_results: int) -> ScrapeOutput:
|
||||||
|
"""Bing organic results — `li.b_algo` is stable since ~2010.
|
||||||
|
|
||||||
|
Same captcha-after-extraction strategy as Google.
|
||||||
|
"""
|
||||||
|
title = await page.title()
|
||||||
|
html = await page.content()
|
||||||
|
|
||||||
|
try:
|
||||||
|
locator = page.locator("li.b_algo")
|
||||||
|
count = await locator.count()
|
||||||
|
results: list[tuple[str, str, str]] = []
|
||||||
|
for i in range(min(count, max_results)):
|
||||||
|
li = locator.nth(i)
|
||||||
|
try:
|
||||||
|
a = li.locator("h2 a").first
|
||||||
|
href = await a.get_attribute("href")
|
||||||
|
title_text = (await a.inner_text()).strip()
|
||||||
|
snippet = ""
|
||||||
|
try:
|
||||||
|
snippet_locator = li.locator(".b_caption p, .b_lineclamp2, .b_paractl")
|
||||||
|
if await snippet_locator.count() > 0:
|
||||||
|
snippet = (await snippet_locator.first.inner_text()).strip()[:300]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if href and title_text:
|
||||||
|
results.append((href, title_text, snippet))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if results:
|
||||||
|
return ScrapeOutput(results=results)
|
||||||
|
# Zero results — check captcha markers
|
||||||
|
_, captcha = _detect_block(html, title)
|
||||||
|
if captcha:
|
||||||
|
return ScrapeOutput(results=[], captcha=True, error="captcha_after_zero_results")
|
||||||
|
return ScrapeOutput(results=[], blocked=True, error="no_li_b_algo_or_empty")
|
||||||
|
except Exception as e:
|
||||||
|
return ScrapeOutput(results=[], blocked=True, error=f"bing_scrape_error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _scrape_ddg(page, max_results: int) -> ScrapeOutput:
|
||||||
|
"""DuckDuckGo HTML endpoint (html.duckduckgo.com/html). Captcha-after-extraction."""
|
||||||
|
title = await page.title()
|
||||||
|
html = await page.content()
|
||||||
|
|
||||||
|
selectors_to_try = ["div.result", "div.web-result"]
|
||||||
|
for sel in selectors_to_try:
|
||||||
|
try:
|
||||||
|
locator = page.locator(sel)
|
||||||
|
count = await locator.count()
|
||||||
|
if count == 0:
|
||||||
|
continue
|
||||||
|
results: list[tuple[str, str, str]] = []
|
||||||
|
for i in range(min(count, max_results)):
|
||||||
|
el = locator.nth(i)
|
||||||
|
try:
|
||||||
|
a = el.locator("a.result__a, h2 a").first
|
||||||
|
href = await a.get_attribute("href")
|
||||||
|
title_text = (await a.inner_text()).strip()
|
||||||
|
# DDG html sometimes wraps href in a redirect; resolve.
|
||||||
|
if href and "uddg=" in href:
|
||||||
|
try:
|
||||||
|
qs = parse_qs(urlparse(href).query)
|
||||||
|
real = qs.get("uddg", [None])[0]
|
||||||
|
if real:
|
||||||
|
href = unquote(real)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
snippet = ""
|
||||||
|
try:
|
||||||
|
s = el.locator(".result__snippet").first
|
||||||
|
if await s.count() > 0:
|
||||||
|
snippet = (await s.inner_text()).strip()[:300]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if href and title_text:
|
||||||
|
results.append((href, title_text, snippet))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if results:
|
||||||
|
return ScrapeOutput(results=results)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("DDG selector '%s' failed: %s", sel, e)
|
||||||
|
_, captcha = _detect_block(html, title)
|
||||||
|
if captcha:
|
||||||
|
return ScrapeOutput(results=[], captcha=True, error="captcha_after_zero_results")
|
||||||
|
return ScrapeOutput(results=[], blocked=True, error="no_results_no_selectors_matched")
|
||||||
|
|
||||||
|
|
||||||
|
_SCRAPERS = {
|
||||||
|
"google": _scrape_google,
|
||||||
|
"bing": _scrape_bing,
|
||||||
|
"ddg": _scrape_ddg,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape(engine: str, page, max_results: int) -> ScrapeOutput:
|
||||||
|
"""Dispatch to the engine-specific scraper."""
|
||||||
|
fn = _SCRAPERS.get(engine)
|
||||||
|
if not fn:
|
||||||
|
return ScrapeOutput(results=[], error=f"unsupported_engine:{engine}")
|
||||||
|
return await fn(page, max_results)
|
||||||
208
ai_platform/modules/cloak/src/cloak/server.py
Normal file
208
ai_platform/modules/cloak/src/cloak/server.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
"""FastAPI server — POST /v1/search, GET /health."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from .browser_pool import BrowserPool
|
||||||
|
from .config import CloakSettings
|
||||||
|
from .schemas import (
|
||||||
|
EngineStats,
|
||||||
|
HealthResponse,
|
||||||
|
SearchRequest,
|
||||||
|
SearchResponse,
|
||||||
|
SearchResult,
|
||||||
|
)
|
||||||
|
from .scraper import scrape, search_url
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||||
|
)
|
||||||
|
logger = logging.getLogger("cloak.server")
|
||||||
|
|
||||||
|
|
||||||
|
settings = CloakSettings()
|
||||||
|
logger.setLevel(settings.log_level.upper())
|
||||||
|
|
||||||
|
pool: BrowserPool | None = None
|
||||||
|
_engine_last_call: dict[str, float] = {}
|
||||||
|
_engine_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
"""Spin up the browser pool on startup, tear down on shutdown."""
|
||||||
|
global pool
|
||||||
|
pool = BrowserPool(size=settings.pool_size, humanize=settings.humanize)
|
||||||
|
try:
|
||||||
|
await pool.start()
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.error("Failed to warm browser pool: %s", e)
|
||||||
|
# Continue running — pool may recover via lazy creation on next acquire
|
||||||
|
logger.info("cloak service ready on port %d (pool=%d)", settings.port, settings.pool_size)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if pool:
|
||||||
|
await pool.stop()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="cloak",
|
||||||
|
version="0.1.0",
|
||||||
|
description="Stealth Chromium scraping service. Scrapes Google/Bing/DuckDuckGo SERPs.",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_auth(authorization: str | None = Header(default=None)) -> None:
|
||||||
|
"""Optional bearer-token check (skipped when CLOAK_AUTH_TOKEN is empty)."""
|
||||||
|
if not settings.auth_token:
|
||||||
|
return
|
||||||
|
if not authorization or not authorization.startswith("Bearer "):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
|
||||||
|
if authorization.removeprefix("Bearer ").strip() != settings.auth_token:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", response_model=HealthResponse, dependencies=[Depends(_require_auth)] if False else [])
|
||||||
|
async def health() -> HealthResponse:
|
||||||
|
p = pool
|
||||||
|
if p is None:
|
||||||
|
return HealthResponse(status="unhealthy", pool_size=0, pool_available=0, version="0.1.0")
|
||||||
|
status_label = "healthy" if p.available > 0 else "degraded"
|
||||||
|
return HealthResponse(
|
||||||
|
status=status_label,
|
||||||
|
pool_size=p.size,
|
||||||
|
pool_available=p.available,
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _throttle(engine: str) -> None:
|
||||||
|
"""Enforce a minimum interval between requests to the same engine."""
|
||||||
|
if settings.engine_min_interval_ms <= 0:
|
||||||
|
return
|
||||||
|
async with _engine_lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
last = _engine_last_call.get(engine, 0.0)
|
||||||
|
gap = (now - last) * 1000
|
||||||
|
wait = settings.engine_min_interval_ms - gap
|
||||||
|
if wait > 0:
|
||||||
|
await asyncio.sleep(wait / 1000)
|
||||||
|
_engine_last_call[engine] = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
async def _scrape_one(
|
||||||
|
engine: str,
|
||||||
|
query: str,
|
||||||
|
max_results: int,
|
||||||
|
language: str | None,
|
||||||
|
) -> tuple[list[SearchResult], EngineStats]:
|
||||||
|
"""Run one (engine, query) scrape and return parsed results + stats."""
|
||||||
|
assert pool is not None
|
||||||
|
t0 = time.monotonic()
|
||||||
|
url = search_url(engine, query, language)
|
||||||
|
out_results: list[SearchResult] = []
|
||||||
|
stat = EngineStats(
|
||||||
|
engine=engine, query=query, results_count=0,
|
||||||
|
blocked=False, captcha=False, elapsed_ms=0, error=None,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await _throttle(engine)
|
||||||
|
async with pool.acquire() as browser:
|
||||||
|
page = await browser.new_page()
|
||||||
|
try:
|
||||||
|
await page.goto(url, timeout=settings.page_timeout_ms, wait_until="domcontentloaded")
|
||||||
|
await page.wait_for_timeout(800) # let JS settle
|
||||||
|
output = await scrape(engine, page, max_results)
|
||||||
|
stat.blocked = output.blocked
|
||||||
|
stat.captcha = output.captcha
|
||||||
|
if output.error:
|
||||||
|
stat.error = output.error
|
||||||
|
for rank, (u, t, s) in enumerate(output.results, start=1):
|
||||||
|
out_results.append(SearchResult(
|
||||||
|
url=u, title=t, snippet=s,
|
||||||
|
engine=engine, query=query, rank=rank,
|
||||||
|
))
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
await page.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
stat.error = "timeout"
|
||||||
|
stat.blocked = True
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
stat.error = f"{type(e).__name__}: {str(e)[:100]}"
|
||||||
|
stat.blocked = True
|
||||||
|
|
||||||
|
stat.results_count = len(out_results)
|
||||||
|
stat.elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||||||
|
return out_results, stat
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/v1/search", response_model=SearchResponse, dependencies=[Depends(_require_auth)])
|
||||||
|
async def search(req: SearchRequest, request: Request) -> SearchResponse:
|
||||||
|
"""Run search across all (engine × query) pairs in parallel."""
|
||||||
|
if pool is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Pool not ready")
|
||||||
|
|
||||||
|
# Apply caps
|
||||||
|
queries = req.queries[: settings.max_queries]
|
||||||
|
engines = req.engines[: settings.max_engines]
|
||||||
|
per_engine = min(req.max_results_per_engine, settings.max_results_cap)
|
||||||
|
|
||||||
|
t0 = time.monotonic()
|
||||||
|
|
||||||
|
async def _runner():
|
||||||
|
tasks = [
|
||||||
|
_scrape_one(e, q, per_engine, req.language)
|
||||||
|
for e in engines for q in queries
|
||||||
|
]
|
||||||
|
return await asyncio.gather(*tasks, return_exceptions=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
gathered = await asyncio.wait_for(_runner(), timeout=settings.search_timeout_sec)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
raise HTTPException(status_code=504, detail=f"Search exceeded {settings.search_timeout_sec}s")
|
||||||
|
|
||||||
|
all_results: list[SearchResult] = []
|
||||||
|
stats: list[EngineStats] = []
|
||||||
|
for results, stat in gathered:
|
||||||
|
all_results.extend(results)
|
||||||
|
stats.append(stat)
|
||||||
|
|
||||||
|
elapsed = int((time.monotonic() - t0) * 1000)
|
||||||
|
logger.info(
|
||||||
|
"search: q=%d e=%d -> results=%d in %dms",
|
||||||
|
len(queries), len(engines), len(all_results), elapsed,
|
||||||
|
)
|
||||||
|
return SearchResponse(results=all_results, stats=stats, total_elapsed_ms=elapsed)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def _generic(_: Request, exc: Exception): # noqa: ARG001
|
||||||
|
logger.exception("Unhandled error")
|
||||||
|
return JSONResponse(status_code=500, content={"error": str(exc)[:200]})
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run(
|
||||||
|
"cloak.server:app",
|
||||||
|
host=settings.host,
|
||||||
|
port=settings.port,
|
||||||
|
log_level=settings.log_level.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
0
ai_platform/modules/cloak/tests/__init__.py
Normal file
0
ai_platform/modules/cloak/tests/__init__.py
Normal file
50
ai_platform/modules/cloak/tests/test_schemas.py
Normal file
50
ai_platform/modules/cloak/tests/test_schemas.py
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""Quick schema validation tests — run with `pytest tests/`."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from cloak.schemas import SearchRequest, SearchResponse, EngineStats, SearchResult
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_request_minimal():
|
||||||
|
req = SearchRequest(queries=["BNR confiscare"])
|
||||||
|
assert req.queries == ["BNR confiscare"]
|
||||||
|
assert req.engines == ["google", "bing", "ddg"]
|
||||||
|
assert req.max_results_per_engine == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_request_rejects_unknown_engine():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
SearchRequest(queries=["x"], engines=["yahoo"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_request_rejects_empty_queries():
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
SearchRequest(queries=[])
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_response_round_trip():
|
||||||
|
resp = SearchResponse(
|
||||||
|
results=[
|
||||||
|
SearchResult(
|
||||||
|
url="https://example.com",
|
||||||
|
title="Example",
|
||||||
|
snippet="...",
|
||||||
|
engine="google",
|
||||||
|
query="test",
|
||||||
|
rank=1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
stats=[
|
||||||
|
EngineStats(
|
||||||
|
engine="google", query="test",
|
||||||
|
results_count=1, elapsed_ms=2000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
total_elapsed_ms=2050,
|
||||||
|
)
|
||||||
|
j = resp.model_dump_json()
|
||||||
|
parsed = SearchResponse.model_validate_json(j)
|
||||||
|
assert len(parsed.results) == 1
|
||||||
|
assert parsed.stats[0].engine == "google"
|
||||||
9
ai_platform/modules/dashboard/.dockerignore
Normal file
9
ai_platform/modules/dashboard/.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
**/.venv
|
||||||
|
.git
|
||||||
|
.pytest_cache
|
||||||
|
|
||||||
|
# Don't copy node deps into build context — Dockerfile installs fresh
|
||||||
|
web/node_modules
|
||||||
|
web/dist
|
||||||
83
ai_platform/modules/dashboard/.env.example
Normal file
83
ai_platform/modules/dashboard/.env.example
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
# =============================================================================
|
||||||
|
# Dashboard Module — Environment Configuration
|
||||||
|
# =============================================================================
|
||||||
|
# Copy this file to .env and fill in the values marked with CHANGE_ME.
|
||||||
|
# All variables use the DASHBOARD_ prefix.
|
||||||
|
#
|
||||||
|
# Most dashboard settings mirror what web-api uses — the dashboard talks
|
||||||
|
# to the same provider APIs to display live quota + billing info.
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# REQUIRED — PostgreSQL
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# These are used by both the postgres container and the dashboard app.
|
||||||
|
# Change the password before deploying to a shared environment.
|
||||||
|
DASHBOARD_DB_USER=dashboard
|
||||||
|
DASHBOARD_DB_PASSWORD=CHANGE_ME_secure_password
|
||||||
|
DASHBOARD_DB_NAME=dashboard
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# REQUIRED — External URL for OpenAPI spec
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
DASHBOARD_EXTERNAL_URL=http://localhost:51300
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# OPTIONAL — Provider API keys (for live quota / billing pages)
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# These are read-only — the dashboard queries each provider's /account
|
||||||
|
# endpoint to show how many credits are left. Leave blank to disable
|
||||||
|
# that provider's card.
|
||||||
|
# DASHBOARD_SERPAPI_API_KEY=CHANGE_ME_or_leave_blank
|
||||||
|
# DASHBOARD_TAVILY_API_KEY=CHANGE_ME_or_leave_blank
|
||||||
|
# DASHBOARD_BRAVE_API_KEY=CHANGE_ME_or_leave_blank
|
||||||
|
# DASHBOARD_LINKUP_API_KEY=CHANGE_ME_or_leave_blank
|
||||||
|
# DASHBOARD_EXA_API_KEY=CHANGE_ME_or_leave_blank
|
||||||
|
# DASHBOARD_OPENROUTER_API_KEY=CHANGE_ME_sk-or-v1-...
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# OPTIONAL — Upstream service URLs for health checks
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# The dashboard probes these to show their status on /providers page.
|
||||||
|
# Point the LLM URLs at your GPU machine (no GPU on this host).
|
||||||
|
DASHBOARD_WEB_API_URL=http://didiAI-web-api:51100
|
||||||
|
DASHBOARD_SEARXNG_URL=http://didiAI-web-searxng:8080
|
||||||
|
DASHBOARD_LLM_API_URL=CHANGE_ME_http://your-gpu-host:14011
|
||||||
|
DASHBOARD_VLLM_QWEN_URL=CHANGE_ME_http://your-gpu-host:14001
|
||||||
|
# Comma-separated list of llama.cpp / vLLM servers (load balanced)
|
||||||
|
# DASHBOARD_LLAMACPP_URLS=http://10.11.10.18:14001,http://10.11.10.19:14001
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# OPTIONAL — Retention & caching
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# How many days of request_history to keep (older rows are purged hourly).
|
||||||
|
DASHBOARD_HISTORY_RETENTION_DAYS=30
|
||||||
|
# How long to cache provider quota responses (seconds).
|
||||||
|
DASHBOARD_PROVIDER_STATS_CACHE_SECONDS=30
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# OPTIONAL — Authentication
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Legacy static tokens — leave blank and use DB-backed users instead:
|
||||||
|
# docker exec didiAI-dashboard python -m dashboard.cli create-user <name>
|
||||||
|
# DASHBOARD_API_TOKENS=
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# OPTIONAL — Server settings
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DASHBOARD_HOST=0.0.0.0
|
||||||
|
# DASHBOARD_PORT=51300
|
||||||
|
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# OPTIONAL — Logging
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# DASHBOARD_LOG_LEVEL=INFO
|
||||||
|
# DASHBOARD_LOG_JSON=false
|
||||||
274
ai_platform/modules/dashboard/INDEX.md
Normal file
274
ai_platform/modules/dashboard/INDEX.md
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
# Dashboard Module - INDEX
|
||||||
|
|
||||||
|
AI platform monitoring/admin dashboard. Browse archived claims, view ingest history, audit trail, runtime config overrides, costs per provider/tier, and live provider quotas. Single FastAPI service serving:
|
||||||
|
1. **React 19 + MUI 7 SPA** at `/admin-ai/` (default modern UI, added in Phase C 2026-05-02)
|
||||||
|
2. **Jinja2 templates** at `/`, `/history`, `/cost`, `/providers`, `/archive`, `/audit`, `/config` (legacy, kept side-by-side until full deprecation)
|
||||||
|
3. **JSON API** at `/api/*` and `/admin-ai/api/*` (dual-mounted) consumed by `web-api` + the SPA itself
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Python 3.10+ (FastAPI 0.115+, Uvicorn)
|
||||||
|
- React 19 + MUI 7 + Vite 7 + react-router 7 + TanStack Query + Recharts (built into `/app/web_dist/`, served as static via SPAStaticFiles with index-fallback)
|
||||||
|
- Jinja2 templates legacy (HTMX + Alpine.js + Tailwind CDN — kept until React reaches 100% parity)
|
||||||
|
- SQLAlchemy 2.0 async + asyncpg, Alembic
|
||||||
|
- pydantic-settings (env prefix `DASHBOARD_`)
|
||||||
|
- httpx for live provider quota fetching, brain proxy
|
||||||
|
- python-jose[cryptography] for Keycloak JWT validation
|
||||||
|
|
||||||
|
## Coordinates
|
||||||
|
|
||||||
|
- **SPA URL**: `https://10.11.10.12:8443/admin-ai/` (via frontend nginx) or `http://10.11.10.12:51300/admin-ai/` (direct)
|
||||||
|
- **Public URL**: `https://didi365.eu/admin-ai/` (via Cloudflare tunnel + frontend nginx)
|
||||||
|
- Container: `didiAI-dashboard` (alongside `didiAI-dashboard-db` on `:15432`)
|
||||||
|
- Compose: `deploy/docker-compose.yml`, profile `dashboard`
|
||||||
|
- Network: `didi-network` (unified single network for all DIDI + AI platform stacks since 2026-05-04)
|
||||||
|
|
||||||
|
## Auth (current state — 2026-05-04)
|
||||||
|
|
||||||
|
**Hybrid auth** in `dependencies.py:verify_bearer_token`:
|
||||||
|
1. `STAGING_MODE=true` → all auth bypassed (default for dev)
|
||||||
|
2. JWT (3 dot-separated parts) → validated via `keycloak_auth.py` (JWKS cache 10min, signature, issuer, exp, role check)
|
||||||
|
3. DB-backed bearer tokens (legacy, from CLI `dashboard create-user`)
|
||||||
|
4. Static `api_tokens` env var (legacy fallback)
|
||||||
|
5. Otherwise → 401
|
||||||
|
|
||||||
|
**Keycloak settings** (`config.py:DashboardSettings`):
|
||||||
|
- `keycloak_url`, `keycloak_realm` (default `didi-clients`), `keycloak_client_id` (default `ai-platform-dashboard`)
|
||||||
|
- `keycloak_required_role` (default `admin` — same role as DIDI admin-dashboard for unified access)
|
||||||
|
- Manual setup: create client + assign role via Keycloak admin or `deploy/setup-keycloak.sh`
|
||||||
|
|
||||||
|
**Cutover from staging to prod**:
|
||||||
|
- Set `DASHBOARD_KEYCLOAK_URL=https://sso.clossers.com`, `DASHBOARD_STAGING_MODE=false`, `VITE_STAGING_MODE=false`
|
||||||
|
- Rebuild image with build args (Dockerfile bakes Keycloak config into JS bundle at build time)
|
||||||
|
|
||||||
|
Plan/runbook: `AI_PLATFORM_RESKIN_PLAN.md` (Phase C.8) + `/home/admin365/didi_mono/UNIFIED_KEYCLOAK_CUTOVER.md`
|
||||||
|
|
||||||
|
## Ce face
|
||||||
|
|
||||||
|
Sections served as HTML pages (`pages.py`) + JSON-mirror endpoints in `routes/`:
|
||||||
|
|
||||||
|
- **Overview** (`/`) - KPIs (totals, error rate, avg duration, total cost) + provider grid (live quotas)
|
||||||
|
- **Providers** (`/providers`) - detailed provider cards + raw table (live SerpAPI/Tavily/Brave/LinkUp/Exa/OpenRouter/internal stats)
|
||||||
|
- **History** (`/history`, `/history/{request_id}`) - request log with filters (tier, provider, endpoint, hours) + drill-down with full stages + raw_request/response
|
||||||
|
- **Cost** (`/cost`) - 24h/7d/30d spend, projected monthly, by-provider, by-tier, top 10 expensive requests, quota-vs-budget bars
|
||||||
|
- **Archive** (`/archive`, `/archive/{claim_id}`) - browse promoted claims + linked articles (permanent storage seeded via `POST /archive/promote/{request_id}`)
|
||||||
|
- **Audit** (`/audit`) - audit log entries (config.set, config.reset, config.delete, archive.promote)
|
||||||
|
- **Config** (`/config`) - runtime overrides table grouped by category (providers/routing/llm/tiers); HTMX in-place edit + reset
|
||||||
|
|
||||||
|
## API endpoints
|
||||||
|
|
||||||
|
### `routes/health.py`
|
||||||
|
- `GET /health` - liveness + DB ping
|
||||||
|
- `GET /ready` - app.state populated check
|
||||||
|
|
||||||
|
### `routes/ingest.py` (no auth, service-to-service)
|
||||||
|
- `POST /api/ingest/event` - receives request events from `web-api` middleware; trims fields, computes cost via `pricing.estimate_cost` if missing, inserts into `request_history`
|
||||||
|
|
||||||
|
### `routes/history.py` (no auth, read-only)
|
||||||
|
- `GET /api/history` - filtered list (tier, provider, endpoint, hours, limit, offset)
|
||||||
|
- `GET /api/history/{request_id}` - full record (includes stages + raw_request/response)
|
||||||
|
|
||||||
|
### `routes/stats.py` (no auth, read-only)
|
||||||
|
- `GET /api/stats/providers?force=` - live provider stats (cached `provider_stats_cache_seconds`, default 30s)
|
||||||
|
- `GET /api/stats/summary?hours=` - aggregated counters (totals, by_tier, by_provider, by_endpoint, error_rate, avg_duration, total_cost)
|
||||||
|
- `GET /api/stats/timeline?hours=` - hourly buckets (`date_trunc('hour', ...)`) for charts
|
||||||
|
|
||||||
|
### `routes/archive.py`
|
||||||
|
- `GET /api/archive/claims` - paginated claims list with optional `q` ilike search
|
||||||
|
- `GET /api/archive/claims/{claim_id}` - single claim + linked articles
|
||||||
|
- `POST /api/archive/promote/{request_id}` (auth) - promote a `request_history` row into `claims_archive` + `articles_archive` + `claim_articles`; only `/v1/gather` rows can be promoted
|
||||||
|
|
||||||
|
### `routes/config.py`
|
||||||
|
- `GET /api/config` - all keys with overrides merged on top of `KNOWN_KEYS` defaults (consumed by `web-api`, no auth)
|
||||||
|
- `GET /api/config/{key}` - single key
|
||||||
|
- `PUT /api/config/{key}` (auth) - set override, validates against schema (bool/int/enum/csv/string), writes audit log
|
||||||
|
- `DELETE /api/config/{key}` (auth) - revert to default, writes audit log
|
||||||
|
|
||||||
|
`KNOWN_KEYS` in `routes/config.py` enumerates the runtime keys consumed by `web-api`: `web.providers.{serpapi,tavily,brave,linkup,exa}.enabled`, `web.premium.strategy`, `web.premium.priority_order`, `web.openrouter.model`, `web.tier.{free,premium}.max_search_results`.
|
||||||
|
|
||||||
|
### `routes/pages.py` (Jinja HTML)
|
||||||
|
- `GET /` - overview
|
||||||
|
- `GET /history`, `GET /history/{request_id}`
|
||||||
|
- `GET /providers`
|
||||||
|
- `GET /cost`
|
||||||
|
- `GET /archive`, `GET /archive/{claim_id}`
|
||||||
|
- `GET /audit`
|
||||||
|
- `GET /config` + HTMX form handlers `POST /config/{key}` and `POST /config/{key}/reset` (return partial fragments)
|
||||||
|
|
||||||
|
## Structura fisiere
|
||||||
|
|
||||||
|
```
|
||||||
|
src/dashboard/
|
||||||
|
__init__.py
|
||||||
|
cli.py # admin CLI: create-user, list-users, delete-user (python -m dashboard.cli)
|
||||||
|
auth.py # SHA-256 + hmac.compare_digest, User CRUD
|
||||||
|
config.py # DashboardSettings (pydantic-settings, DASHBOARD_ prefix), SettingsCache
|
||||||
|
logging.py # get_logger helper (JSON or text via DASHBOARD_LOG_JSON)
|
||||||
|
pricing.py # estimate_cost(endpoint, tier, provider) - per-provider USD
|
||||||
|
retention.py # 30-day rolling cleanup of request_history
|
||||||
|
api/
|
||||||
|
app.py # FastAPI factory, lifespan (init engine + provider registry), router wiring
|
||||||
|
dependencies.py # get_session, get_registry, verify_bearer_token, get_username
|
||||||
|
routes/
|
||||||
|
archive.py # claims archive CRUD + promote
|
||||||
|
config.py # KNOWN_KEYS + runtime override CRUD
|
||||||
|
health.py # /health, /ready
|
||||||
|
history.py # /api/history (read-only)
|
||||||
|
ingest.py # POST /api/ingest/event (service-to-service)
|
||||||
|
pages.py # all Jinja HTML pages + HTMX handlers
|
||||||
|
stats.py # /api/stats/{providers,summary,timeline}
|
||||||
|
db/
|
||||||
|
models.py # Base, RequestHistory, ProviderStatsHourly, ConfigOverride, User, AuditLog,
|
||||||
|
# ClaimsArchive, ArticlesArchive, ClaimArticle
|
||||||
|
session.py # init_engine, get_session, get_session_factory, close_engine
|
||||||
|
providers/
|
||||||
|
base.py # ProviderStats dataclass + ProviderClient ABC
|
||||||
|
registry.py # ProviderRegistry (cached fan-out across providers)
|
||||||
|
serpapi.py # SerpAPI quota + plan price
|
||||||
|
tavily.py # Tavily quota
|
||||||
|
brave.py # Brave Search quota
|
||||||
|
linkup.py # LinkUp quota
|
||||||
|
openrouter.py # OpenRouter spend
|
||||||
|
internal.py # Internal services (web-api, SearXNG, vLLM, llama.cpp) live health
|
||||||
|
templates/ # Jinja
|
||||||
|
base.html
|
||||||
|
overview.html
|
||||||
|
archive.html, archive_detail.html
|
||||||
|
audit.html
|
||||||
|
config.html
|
||||||
|
cost.html
|
||||||
|
history.html, history_detail.html
|
||||||
|
providers.html
|
||||||
|
partials/
|
||||||
|
config_row.html # HTMX swap target after edit/reset
|
||||||
|
provider_card.html # reusable card on overview + providers pages
|
||||||
|
quota_bar.html # quota progress bar
|
||||||
|
static/
|
||||||
|
css/, js/ # tailwind via CDN, htmx + alpine inline
|
||||||
|
deploy/
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml # didiAI-dashboard + didiAI-dashboard-db (postgres:16-alpine)
|
||||||
|
deploy.sh # convenience wrapper around `docker compose --profile dashboard`
|
||||||
|
tests/
|
||||||
|
...
|
||||||
|
pyproject.toml # hatchling build, ruff inherited from ../../ruff.toml
|
||||||
|
uv.lock
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
`didiAI-dashboard-db` (postgres:16-alpine, host `:15432` -> container `:5432`). Tables:
|
||||||
|
|
||||||
|
- `request_history` - 30-day rolling per-request log (BigInt id, request_id unique, JSON stages/raw_request/raw_response, cost_usd Numeric(12,6), indexes on created_at, tier, provider, endpoint)
|
||||||
|
- `provider_stats_hourly` - rollups by (provider, hour)
|
||||||
|
- `config_overrides` - runtime config k/v overrides (key PK, JSON value, updated_by)
|
||||||
|
- `users` - dashboard users (username unique, token_hash SHA-256, role, last_login)
|
||||||
|
- `audit_log` - mutation history (timestamp, username, action, target, old_value/new_value JSON)
|
||||||
|
- `claims_archive` - permanent claims storage (claim_hash unique, verdict, confidence Numeric(5,4), summary, entities JSON, tags JSON)
|
||||||
|
- `articles_archive` - permanent article full-text (url unique, url_hash unique, full_text, publisher, credibility_score)
|
||||||
|
- `claim_articles` - M2M claims <-> articles (relevance_score, snippet)
|
||||||
|
|
||||||
|
Connection string format: `postgresql+asyncpg://USER:PASS@didiAI-dashboard-db:5432/DB` injected via `DASHBOARD_DATABASE_URL`.
|
||||||
|
|
||||||
|
## Pages
|
||||||
|
|
||||||
|
- `base.html` - layout shell (Tailwind CDN, sidebar nav, HTMX + Alpine includes)
|
||||||
|
- `overview.html` - KPI tiles + provider grid (uses `partials/provider_card.html` + `partials/quota_bar.html`)
|
||||||
|
- `providers.html` - full provider cards + raw quota table
|
||||||
|
- `history.html` / `history_detail.html` - filterable list + drill-down with stages JSON pretty-print
|
||||||
|
- `cost.html` - cost cards + by-provider/by-tier breakdown + budget bars
|
||||||
|
- `archive.html` / `archive_detail.html` - claim search + linked articles
|
||||||
|
- `audit.html` - chronological mutation log
|
||||||
|
- `config.html` - runtime overrides grouped by category, HTMX inline edit -> `partials/config_row.html`
|
||||||
|
|
||||||
|
## Authentication (current state)
|
||||||
|
|
||||||
|
- Bearer token in `Authorization: Bearer <token>` header
|
||||||
|
- `dependencies.verify_bearer_token` reads header, hashes, scans `users`, returns `User` or 401
|
||||||
|
- `dependencies.get_username(principal)` extracts username for audit log
|
||||||
|
- Token issuance via CLI inside container:
|
||||||
|
```
|
||||||
|
docker exec -it didiAI-dashboard python -m dashboard.cli create-user <name> [--email] [--role admin|viewer]
|
||||||
|
```
|
||||||
|
- All read endpoints + `POST /api/ingest/event` are auth-free (VPN-internal trust)
|
||||||
|
|
||||||
|
## Reskin plan (FUTURE - NOT done yet)
|
||||||
|
|
||||||
|
- React 19 + MUI 7 + Keycloak SSO **DONE 2026-05-02** (Phase C in `agent-v3/IMPLEMENTATION_PLAN_HIL_BRAIN.md`, detail in `AI_PLATFORM_RESKIN_PLAN.md`)
|
||||||
|
- Becomes admin-only (Keycloak realm role)
|
||||||
|
- Bearer-token table retired; existing `role` column may persist for historical audit-log mapping
|
||||||
|
- Brain admin UI (atom browse, force-gold, brain stats) added as a new section in this dashboard during Phase C
|
||||||
|
- Jinja templates + HTMX endpoints in `pages.py` will be replaced by JSON endpoints; existing `routes/*.py` JSON API stays as the contract
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/admin365/didi_mono/ai_platform/modules/dashboard/deploy
|
||||||
|
cp ../.env.example .env # set DASHBOARD_DB_USER/PASSWORD/NAME + provider keys
|
||||||
|
./deploy.sh up # docker compose --profile dashboard up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Healthcheck: `python -c urllib.request.urlopen('http://localhost:51300/health')` every 30s
|
||||||
|
- Restart policy: `unless-stopped`
|
||||||
|
- Settings prefix: `DASHBOARD_*` (see `config.py` for full list)
|
||||||
|
|
||||||
|
## Ce NU face
|
||||||
|
|
||||||
|
- ~~No SSO yet~~ Keycloak SSO wired (DONE 2026-05-02). Bearer tokens kept as legacy fallback.
|
||||||
|
- No multi-tenant - single shared `users` table, no per-tenant scoping
|
||||||
|
- No public access - binds to internal `didi-network` network, not exposed via Kong/edge
|
||||||
|
- ~~No React frontend yet~~ React 19 SPA at `/admin-ai/` (DONE 2026-05-02). Jinja kept side-by-side until parity.
|
||||||
|
- No real-time push - HTMX polling, no WebSockets/SSE
|
||||||
|
|
||||||
|
## Related docs
|
||||||
|
|
||||||
|
- AI platform CLAUDE.md: `/home/admin365/didi_mono/ai_platform/CLAUDE.md`
|
||||||
|
- Module README: `/home/admin365/didi_mono/ai_platform/modules/dashboard/README.md`
|
||||||
|
- Reskin + Brain admin plan (Phase C): `/home/admin365/didi_mono/backend/services/orchestration-layer/agent-v3/IMPLEMENTATION_PLAN_HIL_BRAIN.md`
|
||||||
|
- Web-api ingest middleware (the producer for `POST /api/ingest/event`): `/home/admin365/didi_mono/ai_platform/modules/web-api/`
|
||||||
|
|
||||||
|
## Recent Changes (2026-05-05)
|
||||||
|
|
||||||
|
- **3 pagini noi**:
|
||||||
|
- `/admin-ai/operations/live` (Live Status) — KPI 1h cu refresh 5s, Recent Activity feed (latest 20 cu chip warning daca >1h vechi), Provider Health cu cache age, throughput sparkline 6h
|
||||||
|
- `/admin-ai/system/settings` (System Settings) — health endpoint, identity card cu roluri, Configuration Surface (98 chei + breakdown per modul + count overrides), Quick Access dynamic links
|
||||||
|
- `/admin-ai/system/schema` (Schema Overrides) — CRUD UI pentru `config_schema_override` (Register/Edit/Delete via dialog cu validare type/min/max/options)
|
||||||
|
- **Pagini eliminate**: `/admin-ai/system/users` (admin backend gestioneaza userii Keycloak)
|
||||||
|
- **Overview**: card Phase C status chips -> inlocuit cu Quick Links chips clickable (Live Status, History, Cost, Providers, Brain Atoms, Audit Log, Schema Overrides)
|
||||||
|
- **Modules ModulePage** (toate 8): tab "Live State" functional cu real backend `/api/proxy/{moduleId}/health` (status badge, latency, payload `/v1/info`, refresh 10s, env override DASHBOARD_<MOD>_HEALTH_URL); tab "Actions" reformat ca "Pending Restarts" cu lista overrides cu `restart_required=true` + comanda SSH copy-able
|
||||||
|
- **Backend endpoint nou**: `routes/proxy.py` cu `GET /api/proxy/{module_id}/health` — proxy catre modul real, hardcoded URLs pe 10.11.10.17 cu fallback la `DASHBOARD_<MODULE>_HEALTH_URL` env override
|
||||||
|
- **Cost endpoint imbunatatit**: `GET /api/stats/cost` returneaza `projection_basis` (blend 30d=40%/7d=60%, sau 7d_avg, sau 24h_only), `projection_confidence` (stable/moderate/rough), `trend` (increasing/decreasing/stable), `trend_pct`. UI Cost page afiseaza confidence chip + trend arrow (↗↘→).
|
||||||
|
- **Providers endpoint imbunatatit**: `GET /api/stats/providers` adauga `last_refresh` (ISO), `age_seconds`, `cache_ttl_seconds`. UI Providers + Live Status afiseaza "refreshed Xs ago" chip (warning peste 120s).
|
||||||
|
- **ProviderRegistry**: tracks `_cache_wall: datetime` separat de `_cache_time` (monotonic), expune properties `last_refresh_iso` + `age_seconds`.
|
||||||
|
- **Frontend type fix**: `Live.tsx` + `Providers.tsx` foloseau campuri inexistente (`status`, `quota_used_pct`, `plan`) — corectate la `healthy: bool`, `quota_percent_used`, `display_name`, `plan_name` (matching backend response).
|
||||||
|
- **Schema migration**: 98 chei seed la primul startup in `config_schema_override`, vizibile in Schema Overrides UI cu actiuni Edit/Delete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Brain admin pages — Phase D2 (2026-05-05)
|
||||||
|
|
||||||
|
3 rute noi sub `/admin-ai/brain/` + 1 tab nou pe `system/audit`. Toate consumă brain prin proxy-ul `/api/brain/*`.
|
||||||
|
|
||||||
|
### `pages/brain/Facts.tsx`
|
||||||
|
|
||||||
|
Browser pe `brain_fact_status` cu filter (entity ILIKE, predicate exact, current_truth, locked_only, topic), DataGrid paginat. Click pe rând → drawer dreapta cu Triple summary (chip volatility + lock + topics), **Truth timeline** Stepper cu toate `brain_fact_version` rows, **Moderator override form** (moderator_user_id + set_truth + confidence + evidence URLs + lock/unlock + notes → PATCH `/v1/fact_status/{id}`).
|
||||||
|
|
||||||
|
### `pages/brain/Invalidate.tsx`
|
||||||
|
|
||||||
|
Form-driven mass invalidation cu UX 2-step: Build filter (topic_codes, entity_canonicals, claim_pattern, since, invalidate_gold cu warning) → **Preview (dry run)** → **Confirm invalidate** (button enabled doar după Preview). Side panel: ultimele 10 invalidări (auto-refresh 30s).
|
||||||
|
|
||||||
|
### `pages/system/AuditLog.tsx` — refactor cu tabs
|
||||||
|
|
||||||
|
Wrapper cu Tabs: **Dashboard tab** → `AuditLogDashboard.tsx` (existing code extras intact); **Brain tab** → `AuditLogBrain.tsx` nou, citește `/api/brain/v1/cache/audit_log` cu action presets (judge_*, fact_truth_*, invalidate, promote_gold), action chip color-coded, payload tooltip JSON pretty.
|
||||||
|
|
||||||
|
### Backend (`brain_proxy.py`)
|
||||||
|
|
||||||
|
Whitelist extins cu: `/v1/fact_status/list`, `/v1/fact_status/`, `/v1/cache/audit_log`, `/v1/cache/invalidate`, `/v1/canonicalize`. GET pass-through; POST/PATCH cer bearer.
|
||||||
|
|
||||||
|
### `types/brain.ts` extins
|
||||||
|
|
||||||
|
`Volatility`, `FactStatusItem(+List+VersionItem+Versions+Patch)Response`, `AuditLogItem(+Response)`, `CacheInvalidate(Request|Response)`.
|
||||||
|
|
||||||
|
### Routes + sidebar
|
||||||
|
|
||||||
|
`App.tsx`: `brain/facts`, `brain/invalidate`. `AppShell.tsx` Brain Admin section: Fact Status (FactCheckIcon), Invalidate (DeleteSweepIcon).
|
||||||
60
ai_platform/modules/dashboard/README.md
Normal file
60
ai_platform/modules/dashboard/README.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# Dashboard
|
||||||
|
|
||||||
|
Admin dashboard for the didiAI platform. Tracks search provider usage, costs, request history, and exposes runtime configuration.
|
||||||
|
|
||||||
|
## What it does
|
||||||
|
|
||||||
|
- **Live quota & billing** — pulls real-time data from SerpAPI, Tavily, Brave, OpenRouter
|
||||||
|
- **Health monitoring** — SearXNG, web-api, vLLM, llama.cpp servers
|
||||||
|
- **Request history** — 30-day rolling log of every gather/search/fetch request with drill-down
|
||||||
|
- **Cost tracking** — per-provider spend, projections, cost per tier
|
||||||
|
- **Future:** runtime config (toggle providers, change strategies, manage tier caps)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker 24+ with Compose V2
|
||||||
|
- PostgreSQL 16 (provided by compose)
|
||||||
|
- Internal network access to web-api, SearXNG, LLM servers
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy
|
||||||
|
cp ../.env.example .env # edit with your secrets
|
||||||
|
./deploy.sh up
|
||||||
|
```
|
||||||
|
|
||||||
|
Dashboard is now running at http://localhost:51300
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Web UI
|
||||||
|
- `/` — overview with KPIs and provider grid
|
||||||
|
- `/providers` — detailed provider cards + raw table
|
||||||
|
- `/history` — filterable request history
|
||||||
|
- `/history/{request_id}` — full request detail with stages
|
||||||
|
|
||||||
|
### JSON API
|
||||||
|
- `GET /health` — liveness
|
||||||
|
- `GET /api/stats/providers` — live provider stats
|
||||||
|
- `GET /api/stats/summary?hours=24` — aggregated counters
|
||||||
|
- `GET /api/stats/timeline?hours=24` — hourly buckets for charts
|
||||||
|
- `GET /api/history?limit=50&tier=premium` — filtered history
|
||||||
|
- `GET /api/history/{request_id}` — single request with full payload
|
||||||
|
- `POST /api/ingest/event` — receives events from web-api middleware
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
web-api ────► POST /api/ingest/event ────► dashboard-api ────► PostgreSQL
|
||||||
|
│
|
||||||
|
├─ reads provider APIs live
|
||||||
|
└─ serves UI via Jinja2+HTMX
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- FastAPI + Pydantic
|
||||||
|
- SQLAlchemy 2.0 async + asyncpg
|
||||||
|
- Jinja2 + HTMX + Alpine.js + Tailwind (zero build step)
|
||||||
|
- PostgreSQL 16
|
||||||
67
ai_platform/modules/dashboard/deploy/Dockerfile
Normal file
67
ai_platform/modules/dashboard/deploy/Dockerfile
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
# ---- Stage 1: build React SPA ----
|
||||||
|
FROM node:20-alpine AS web-builder
|
||||||
|
WORKDIR /web
|
||||||
|
|
||||||
|
# Build-time SPA config — pass via `--build-arg VITE_*=...`. Defaults
|
||||||
|
# point at the DIDI SSO cluster, which is what production uses.
|
||||||
|
ARG VITE_KEYCLOAK_URL=https://sso.clossers.com
|
||||||
|
ARG VITE_KEYCLOAK_REALM=didi-clients
|
||||||
|
ARG VITE_KEYCLOAK_CLIENT_ID=ai-platform-dashboard
|
||||||
|
ARG VITE_KEYCLOAK_REQUIRED_ROLE=admin
|
||||||
|
ARG VITE_STAGING_MODE=false
|
||||||
|
|
||||||
|
ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL \
|
||||||
|
VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM \
|
||||||
|
VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID \
|
||||||
|
VITE_KEYCLOAK_REQUIRED_ROLE=$VITE_KEYCLOAK_REQUIRED_ROLE \
|
||||||
|
VITE_STAGING_MODE=$VITE_STAGING_MODE
|
||||||
|
|
||||||
|
# Cache deps
|
||||||
|
COPY web/package.json web/package-lock.json* ./
|
||||||
|
RUN npm install --no-audit --no-fund
|
||||||
|
# Build
|
||||||
|
COPY web/index.html web/tsconfig.json web/vite.config.ts ./
|
||||||
|
COPY web/src ./src
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- Stage 2: build Python deps ----
|
||||||
|
FROM python:3.11.12-slim AS builder
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.10 /uv /uvx /bin/
|
||||||
|
|
||||||
|
ENV UV_LINK_MODE=copy \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_PROJECT_ENVIRONMENT=/app/.venv
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
RUN uv venv /app/.venv && uv pip install --python /app/.venv/bin/python \
|
||||||
|
fastapi uvicorn[standard] pydantic pydantic-settings httpx \
|
||||||
|
"sqlalchemy[asyncio]>=2.0.36" asyncpg alembic jinja2 python-multipart
|
||||||
|
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
RUN uv pip install --python /app/.venv/bin/python -e .
|
||||||
|
|
||||||
|
# ---- Stage 3: runtime ----
|
||||||
|
FROM python:3.11.12-slim
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libpq5 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /app /app
|
||||||
|
COPY --from=web-builder /web/dist /app/web_dist
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH" \
|
||||||
|
PYTHONPATH=/app/src \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
EXPOSE 51300
|
||||||
|
|
||||||
|
CMD ["uvicorn", "dashboard.api.app:app", "--host", "0.0.0.0", "--port", "51300"]
|
||||||
50
ai_platform/modules/dashboard/deploy/deploy.sh
Normal file
50
ai_platform/modules/dashboard/deploy/deploy.sh
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
[[ -f .env ]] && set -a && source .env && set +a
|
||||||
|
|
||||||
|
check_required_var() {
|
||||||
|
if [[ -z "${!1:-}" ]]; then
|
||||||
|
echo "ERROR: Required variable $1 not set (check .env)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'EOF'
|
||||||
|
Usage: ./deploy.sh {up|down|logs|restart}
|
||||||
|
|
||||||
|
Actions:
|
||||||
|
up Build and start dashboard + database
|
||||||
|
down Stop containers (volumes preserved)
|
||||||
|
logs Tail logs
|
||||||
|
restart Restart dashboard without rebuild
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
check_required_var DASHBOARD_DB_USER
|
||||||
|
check_required_var DASHBOARD_DB_PASSWORD
|
||||||
|
check_required_var DASHBOARD_DB_NAME
|
||||||
|
check_required_var DASHBOARD_EXTERNAL_URL
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
up)
|
||||||
|
docker compose --profile dashboard up -d --build
|
||||||
|
echo "Dashboard started at ${DASHBOARD_EXTERNAL_URL}"
|
||||||
|
;;
|
||||||
|
down)
|
||||||
|
docker compose --profile dashboard down
|
||||||
|
;;
|
||||||
|
logs)
|
||||||
|
docker compose --profile dashboard logs -f --tail=100
|
||||||
|
;;
|
||||||
|
restart)
|
||||||
|
docker compose --profile dashboard restart
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
usage
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
102
ai_platform/modules/dashboard/deploy/docker-compose.yml
Normal file
102
ai_platform/modules/dashboard/deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
# Dashboard Module - Docker Compose
|
||||||
|
#
|
||||||
|
# Port Allocation (Dev: 51300):
|
||||||
|
# 51300 - Dashboard API + Web UI
|
||||||
|
# 15432 - PostgreSQL (optional host exposure for debugging)
|
||||||
|
#
|
||||||
|
# Naming Convention: didiAI-{module}-{service}
|
||||||
|
|
||||||
|
networks:
|
||||||
|
didi-network:
|
||||||
|
external: true # single shared network for all DIDI + AI platform stacks
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
dashboard_db_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ==========================================================================
|
||||||
|
# PostgreSQL
|
||||||
|
# ==========================================================================
|
||||||
|
dashboard-db:
|
||||||
|
container_name: didiAI-dashboard-db
|
||||||
|
image: postgres:16-alpine
|
||||||
|
networks:
|
||||||
|
- didi-network
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${DASHBOARD_DB_USER}
|
||||||
|
- POSTGRES_PASSWORD=${DASHBOARD_DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=${DASHBOARD_DB_NAME}
|
||||||
|
volumes:
|
||||||
|
- dashboard_db_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "15432:5432"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${DASHBOARD_DB_USER} -d ${DASHBOARD_DB_NAME}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- dashboard
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Dashboard API + Web UI
|
||||||
|
# ==========================================================================
|
||||||
|
dashboard-api:
|
||||||
|
container_name: didiAI-dashboard
|
||||||
|
image: didiai-dashboard
|
||||||
|
build:
|
||||||
|
context: ..
|
||||||
|
dockerfile: deploy/Dockerfile
|
||||||
|
args:
|
||||||
|
# SPA build-time config — Keycloak settings get baked into the JS bundle.
|
||||||
|
# Default: local Keycloak proxied through admin nginx at /auth/.
|
||||||
|
VITE_KEYCLOAK_URL: ${VITE_KEYCLOAK_URL:-https://10.11.10.11:3001/auth}
|
||||||
|
VITE_KEYCLOAK_REALM: ${VITE_KEYCLOAK_REALM:-didi-admins}
|
||||||
|
VITE_KEYCLOAK_CLIENT_ID: ${VITE_KEYCLOAK_CLIENT_ID:-ai-platform-dashboard}
|
||||||
|
VITE_KEYCLOAK_REQUIRED_ROLE: ${VITE_KEYCLOAK_REQUIRED_ROLE:-admin}
|
||||||
|
VITE_STAGING_MODE: ${VITE_STAGING_MODE:-false}
|
||||||
|
ports:
|
||||||
|
- "51300:51300"
|
||||||
|
networks:
|
||||||
|
- didi-network
|
||||||
|
depends_on:
|
||||||
|
dashboard-db:
|
||||||
|
condition: service_healthy
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- DASHBOARD_HOST=0.0.0.0
|
||||||
|
- DASHBOARD_PORT=51300
|
||||||
|
- DASHBOARD_DATABASE_URL=postgresql+asyncpg://${DASHBOARD_DB_USER}:${DASHBOARD_DB_PASSWORD}@didiAI-dashboard-db:5432/${DASHBOARD_DB_NAME}
|
||||||
|
- DASHBOARD_BRAIN_URL=${DASHBOARD_BRAIN_URL:-http://didibrain-api:8090}
|
||||||
|
# Keycloak SSO (leave empty to disable JWT auth)
|
||||||
|
- DASHBOARD_KEYCLOAK_URL=${DASHBOARD_KEYCLOAK_URL:-}
|
||||||
|
- DASHBOARD_KEYCLOAK_REALM=${DASHBOARD_KEYCLOAK_REALM:-didi-clients}
|
||||||
|
- DASHBOARD_KEYCLOAK_CLIENT_ID=${DASHBOARD_KEYCLOAK_CLIENT_ID:-ai-platform-dashboard}
|
||||||
|
- DASHBOARD_KEYCLOAK_REQUIRED_ROLE=${DASHBOARD_KEYCLOAK_REQUIRED_ROLE:-admin}
|
||||||
|
- DASHBOARD_STAGING_MODE=${DASHBOARD_STAGING_MODE:-false}
|
||||||
|
# OTel — traces to Jaeger via OTel Collector
|
||||||
|
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-http://didi-otel-collector:4317}
|
||||||
|
- OTEL_SERVICE_NAME=didiAI-dashboard
|
||||||
|
# Module health endpoint overrides — point at real upstreams
|
||||||
|
# (raw vLLM at 10.11.10.15 instead of the unbuilt FastAPI wrapper at 10.11.10.17:14100/14200)
|
||||||
|
- DASHBOARD_EMBEDDINGS_HEALTH_URL=${DASHBOARD_EMBEDDINGS_HEALTH_URL:-http://10.11.10.15:8200/v1/models}
|
||||||
|
- DASHBOARD_RERANK_HEALTH_URL=${DASHBOARD_RERANK_HEALTH_URL:-http://10.11.10.15:8100/v1/models}
|
||||||
|
# Catalog runs locally on Docker DNS; /v1/status is the aggregated reachability probe
|
||||||
|
# (catalog does not expose /v1/info — it consumes it from other modules)
|
||||||
|
- DASHBOARD_CATALOG_HEALTH_URL=${DASHBOARD_CATALOG_HEALTH_URL:-http://didiAI-catalog-api:11000/v1/status}
|
||||||
|
# Gateway is nginx — only /health is auth-free and inline
|
||||||
|
- DASHBOARD_GATEWAY_HEALTH_URL=${DASHBOARD_GATEWAY_HEALTH_URL:-http://didiAI-gateway:11000/health}
|
||||||
|
# Brain doesn't expose /v1/info — point at /health (matches catalog rationale)
|
||||||
|
- DASHBOARD_BRAIN_HEALTH_URL=${DASHBOARD_BRAIN_HEALTH_URL:-http://didibrain-api:8090/health}
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:51300/health')"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles:
|
||||||
|
- dashboard
|
||||||
209
ai_platform/modules/dashboard/deploy/setup-keycloak.sh
Normal file
209
ai_platform/modules/dashboard/deploy/setup-keycloak.sh
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Set up Keycloak client `ai-platform-dashboard` on sso.clossers.com realm `didi-clients`.
|
||||||
|
# Idempotent — safe to re-run; existing client gets updated to match this config.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# KC_ADMIN_USER=admin KC_ADMIN_PASS=admin123 ./setup-keycloak.sh
|
||||||
|
#
|
||||||
|
# Optional overrides:
|
||||||
|
# KC_BASE_URL=https://sso.clossers.com
|
||||||
|
# KC_REALM=didi-clients
|
||||||
|
# KC_CLIENT_ID=ai-platform-dashboard
|
||||||
|
# DASHBOARD_HOST=10.11.10.12 # used to build redirect URI
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Keycloak admin endpoints redirect public sso.clossers.com → internal
|
||||||
|
# sso.clossers.local. Token issuer must match the host you call, so we use the
|
||||||
|
# internal name end-to-end (this host has DNS for it).
|
||||||
|
KC_BASE_URL="${KC_BASE_URL:-https://sso.clossers.local}"
|
||||||
|
KC_REALM="${KC_REALM:-didi-clients}"
|
||||||
|
KC_CLIENT_ID="${KC_CLIENT_ID:-ai-platform-dashboard}"
|
||||||
|
KC_ADMIN_USER="${KC_ADMIN_USER:-admin}"
|
||||||
|
KC_ADMIN_PASS="${KC_ADMIN_PASS:?Set KC_ADMIN_PASS}"
|
||||||
|
DASHBOARD_HOST="${DASHBOARD_HOST:-10.11.10.12}"
|
||||||
|
DASHBOARD_PORT="${DASHBOARD_PORT:-51300}"
|
||||||
|
|
||||||
|
echo "→ Authenticating to Keycloak master realm…"
|
||||||
|
ADMIN_TOKEN=$(
|
||||||
|
curl -ksS -X POST "$KC_BASE_URL/realms/master/protocol/openid-connect/token" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "client_id=admin-cli&grant_type=password&username=$KC_ADMIN_USER&password=$KC_ADMIN_PASS" \
|
||||||
|
| python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])"
|
||||||
|
)
|
||||||
|
[ -n "$ADMIN_TOKEN" ] || { echo "FAIL: could not get admin token"; exit 1; }
|
||||||
|
echo " OK (token length ${#ADMIN_TOKEN})"
|
||||||
|
|
||||||
|
# ---------- Step 1: ensure realm role 'admin' exists ----------
|
||||||
|
echo "→ Checking realm role 'admin' exists in realm '$KC_REALM'…"
|
||||||
|
RAW=$(curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
"$KC_BASE_URL/admin/realms/$KC_REALM/roles/admin")
|
||||||
|
if echo "$RAW" | grep -q '"name"'; then
|
||||||
|
echo " OK — role 'admin' present"
|
||||||
|
else
|
||||||
|
echo " NOT found — creating…"
|
||||||
|
curl -ksSL -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name":"admin","description":"Platform admin (DIDI + AI platform)"}' \
|
||||||
|
"$KC_BASE_URL/admin/realms/$KC_REALM/roles"
|
||||||
|
echo " CREATED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- Step 2: ensure client `ai-platform-dashboard` exists ----------
|
||||||
|
echo "→ Checking client '$KC_CLIENT_ID' exists…"
|
||||||
|
EXISTING=$(curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$KC_CLIENT_ID")
|
||||||
|
CLIENT_UUID=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['id'] if d else '')")
|
||||||
|
|
||||||
|
REDIRECTS=$(python3 -c "
|
||||||
|
import json
|
||||||
|
print(json.dumps([
|
||||||
|
'http://$DASHBOARD_HOST:$DASHBOARD_PORT/admin-ai/*',
|
||||||
|
'https://$DASHBOARD_HOST:$DASHBOARD_PORT/admin-ai/*',
|
||||||
|
'http://localhost:$DASHBOARD_PORT/admin-ai/*',
|
||||||
|
'http://localhost:5173/*',
|
||||||
|
# Reverse-proxy URL (admin-dashboard nginx exposes /admin-ai/ on port 3000)
|
||||||
|
'https://$DASHBOARD_HOST:3000/admin-ai/*',
|
||||||
|
]))
|
||||||
|
")
|
||||||
|
|
||||||
|
PAYLOAD=$(python3 -c "
|
||||||
|
import json, os
|
||||||
|
print(json.dumps({
|
||||||
|
'clientId': '$KC_CLIENT_ID',
|
||||||
|
'name': 'AI Platform Admin Dashboard',
|
||||||
|
'description': 'Reskinned admin dashboard for the AI platform (web/llm/embeddings/rerank/audio/video/catalog/brain)',
|
||||||
|
'rootUrl': 'http://$DASHBOARD_HOST:$DASHBOARD_PORT/admin-ai/',
|
||||||
|
'baseUrl': '/admin-ai/',
|
||||||
|
'enabled': True,
|
||||||
|
'protocol': 'openid-connect',
|
||||||
|
'publicClient': True,
|
||||||
|
'standardFlowEnabled': True,
|
||||||
|
'directAccessGrantsEnabled': False,
|
||||||
|
'serviceAccountsEnabled': False,
|
||||||
|
'frontchannelLogout': True,
|
||||||
|
'redirectUris': $REDIRECTS,
|
||||||
|
'webOrigins': ['+'],
|
||||||
|
'attributes': {
|
||||||
|
'pkce.code.challenge.method': 'S256',
|
||||||
|
'post.logout.redirect.uris': '+',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
")
|
||||||
|
|
||||||
|
if [ -z "$CLIENT_UUID" ]; then
|
||||||
|
echo " NOT found — creating…"
|
||||||
|
curl -ksSL -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"$KC_BASE_URL/admin/realms/$KC_REALM/clients"
|
||||||
|
CLIENT_UUID=$(
|
||||||
|
curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$KC_CLIENT_ID" \
|
||||||
|
| python3 -c "import json,sys; print(json.load(sys.stdin)[0]['id'])"
|
||||||
|
)
|
||||||
|
echo " CREATED uuid=$CLIENT_UUID"
|
||||||
|
else
|
||||||
|
echo " Found uuid=$CLIENT_UUID — updating to current spec…"
|
||||||
|
curl -ksSL -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$PAYLOAD" \
|
||||||
|
"$KC_BASE_URL/admin/realms/$KC_REALM/clients/$CLIENT_UUID"
|
||||||
|
echo " UPDATED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- Step 3: smoke test JWKS reachable ----------
|
||||||
|
echo "→ Smoke test: JWKS endpoint reachable…"
|
||||||
|
JWKS_KEYS=$(curl -ksS \
|
||||||
|
"$KC_BASE_URL/realms/$KC_REALM/protocol/openid-connect/certs" \
|
||||||
|
| python3 -c "import json,sys; print(len(json.load(sys.stdin).get('keys',[])))")
|
||||||
|
echo " OK — JWKS returns $JWKS_KEYS keys"
|
||||||
|
|
||||||
|
# ---------- Step 3b: ensure admin-dashboard client also exists (DIDI side) ----------
|
||||||
|
# This is a no-op when the client already exists with correct config; included
|
||||||
|
# so a fresh Keycloak install gets both apps wired in one go.
|
||||||
|
ADMIN_DASH_CLIENT="admin-dashboard"
|
||||||
|
echo "→ Checking client '$ADMIN_DASH_CLIENT' (DIDI admin) exists…"
|
||||||
|
EXISTING_AD=$(curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$ADMIN_DASH_CLIENT")
|
||||||
|
ADMIN_DASH_UUID=$(echo "$EXISTING_AD" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['id'] if d else '')")
|
||||||
|
|
||||||
|
ADMIN_DASH_REDIRECTS=$(python3 -c "
|
||||||
|
import json
|
||||||
|
print(json.dumps([
|
||||||
|
'https://$DASHBOARD_HOST:3000/admin/*',
|
||||||
|
'https://$DASHBOARD_HOST:3000/admin-backend/*',
|
||||||
|
'http://localhost:3000/admin/*',
|
||||||
|
]))
|
||||||
|
")
|
||||||
|
|
||||||
|
ADMIN_DASH_PAYLOAD=$(python3 -c "
|
||||||
|
import json
|
||||||
|
print(json.dumps({
|
||||||
|
'clientId': '$ADMIN_DASH_CLIENT',
|
||||||
|
'name': 'DIDI Admin Dashboard',
|
||||||
|
'description': 'Backend admin (framework config, users, moderation queue, history)',
|
||||||
|
'rootUrl': 'https://$DASHBOARD_HOST:3000/admin/',
|
||||||
|
'baseUrl': '/admin/',
|
||||||
|
'enabled': True,
|
||||||
|
'protocol': 'openid-connect',
|
||||||
|
'publicClient': True,
|
||||||
|
'standardFlowEnabled': True,
|
||||||
|
'directAccessGrantsEnabled': False,
|
||||||
|
'redirectUris': $ADMIN_DASH_REDIRECTS,
|
||||||
|
'webOrigins': ['+'],
|
||||||
|
'attributes': {
|
||||||
|
'pkce.code.challenge.method': 'S256',
|
||||||
|
'post.logout.redirect.uris': '+',
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
")
|
||||||
|
|
||||||
|
if [ -z "$ADMIN_DASH_UUID" ]; then
|
||||||
|
echo " NOT found — creating…"
|
||||||
|
curl -ksSL -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "$ADMIN_DASH_PAYLOAD" \
|
||||||
|
"$KC_BASE_URL/admin/realms/$KC_REALM/clients"
|
||||||
|
ADMIN_DASH_UUID=$(
|
||||||
|
curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||||
|
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$ADMIN_DASH_CLIENT" \
|
||||||
|
| python3 -c "import json,sys; print(json.load(sys.stdin)[0]['id'])"
|
||||||
|
)
|
||||||
|
echo " CREATED uuid=$ADMIN_DASH_UUID"
|
||||||
|
else
|
||||||
|
echo " Found uuid=$ADMIN_DASH_UUID — leaving config as-is (would overwrite custom redirect URIs)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- Step 4: print summary ----------
|
||||||
|
cat <<EOF
|
||||||
|
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Keycloak setup complete.
|
||||||
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
|
Realm: $KC_REALM
|
||||||
|
Client: $KC_CLIENT_ID (uuid=$CLIENT_UUID)
|
||||||
|
Role: admin (realm)
|
||||||
|
Issuer: $KC_BASE_URL/realms/$KC_REALM
|
||||||
|
JWKS: $KC_BASE_URL/realms/$KC_REALM/protocol/openid-connect/certs
|
||||||
|
|
||||||
|
REDIRECTS:
|
||||||
|
$(python3 -c "import json; [print(f' - {u}') for u in $REDIRECTS]")
|
||||||
|
|
||||||
|
NEXT STEPS:
|
||||||
|
1. Verify YOUR user has the 'admin' realm role assigned in $KC_REALM.
|
||||||
|
(If you can already log into the DIDI admin-dashboard, you do.)
|
||||||
|
|
||||||
|
2. Update dashboard/.env:
|
||||||
|
DASHBOARD_KEYCLOAK_URL=$KC_BASE_URL
|
||||||
|
DASHBOARD_STAGING_MODE=false
|
||||||
|
VITE_STAGING_MODE=false
|
||||||
|
|
||||||
|
3. Rebuild + redeploy:
|
||||||
|
cd /home/admin365/didi_mono/ai_platform/modules/dashboard/deploy
|
||||||
|
docker compose --profile dashboard up -d --build dashboard-api
|
||||||
|
|
||||||
|
4. Open http://$DASHBOARD_HOST:$DASHBOARD_PORT/v2/
|
||||||
|
→ Keycloak login redirect → enter creds → SPA loads with your JWT.
|
||||||
|
|
||||||
|
EOF
|
||||||
47
ai_platform/modules/dashboard/pyproject.toml
Normal file
47
ai_platform/modules/dashboard/pyproject.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
[project]
|
||||||
|
name = "dashboard"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Admin dashboard for web search providers, cost tracking, and config management"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.115.0",
|
||||||
|
"uvicorn[standard]>=0.32.0",
|
||||||
|
"pydantic>=2.9.0",
|
||||||
|
"pydantic-settings>=2.6.0",
|
||||||
|
"httpx>=0.27.0",
|
||||||
|
"sqlalchemy[asyncio]>=2.0.36",
|
||||||
|
"asyncpg>=0.30.0",
|
||||||
|
"alembic>=1.14.0",
|
||||||
|
"jinja2>=3.1.4",
|
||||||
|
"python-multipart>=0.0.12",
|
||||||
|
"python-jose[cryptography]>=3.3.0",
|
||||||
|
"prometheus-fastapi-instrumentator>=7.0.0",
|
||||||
|
"opentelemetry-instrumentation-fastapi>=0.50b0",
|
||||||
|
"opentelemetry-instrumentation-httpx>=0.50b0",
|
||||||
|
"opentelemetry-exporter-otlp-proto-grpc>=1.30.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=8.0.0",
|
||||||
|
"pytest-asyncio>=0.24.0",
|
||||||
|
"pytest-cov>=4.0.0",
|
||||||
|
"ruff>=0.8.0",
|
||||||
|
"respx>=0.21.0",
|
||||||
|
"mypy>=1.0.0",
|
||||||
|
"aiosqlite>=0.20.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[tool.hatch.build.targets.wheel]
|
||||||
|
packages = ["src/dashboard"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
extend = "../../ruff.toml"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
testpaths = ["tests"]
|
||||||
3
ai_platform/modules/dashboard/src/dashboard/__init__.py
Normal file
3
ai_platform/modules/dashboard/src/dashboard/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""Dashboard module — admin UI for web search providers and costs."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""FastAPI application."""
|
||||||
185
ai_platform/modules/dashboard/src/dashboard/api/app.py
Normal file
185
ai_platform/modules/dashboard/src/dashboard/api/app.py
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
"""FastAPI application factory."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.types import Scope
|
||||||
|
|
||||||
|
|
||||||
|
class SPAStaticFiles(StaticFiles):
|
||||||
|
"""StaticFiles that falls back to index.html for client-side routes.
|
||||||
|
|
||||||
|
`StaticFiles(html=True)` only serves index.html for the mount root —
|
||||||
|
/v2/brain/atoms etc. would 404 since there's no file at that path.
|
||||||
|
React-router with BrowserRouter needs the SPA shell served for any
|
||||||
|
unmatched URL so the JS bundle can pick up the route. We override
|
||||||
|
`lookup_path` to return index.html as a final fallback (after the
|
||||||
|
standard html=True attempts: path, path+".html", path+"/index.html").
|
||||||
|
"""
|
||||||
|
|
||||||
|
def lookup_path(self, path: str): # type: ignore[override]
|
||||||
|
full_path, stat_result = super().lookup_path(path)
|
||||||
|
if stat_result is None:
|
||||||
|
# Fall back to root index.html so React Router can handle it
|
||||||
|
return super().lookup_path("index.html")
|
||||||
|
return full_path, stat_result
|
||||||
|
|
||||||
|
from dashboard.api.routes import (
|
||||||
|
archive,
|
||||||
|
audit,
|
||||||
|
brain_proxy,
|
||||||
|
catalog,
|
||||||
|
config,
|
||||||
|
health,
|
||||||
|
history,
|
||||||
|
ingest,
|
||||||
|
monitoring,
|
||||||
|
pages,
|
||||||
|
proxy,
|
||||||
|
stats,
|
||||||
|
)
|
||||||
|
from dashboard.config import SettingsCache
|
||||||
|
from dashboard.db.session import close_engine, create_all_tables, init_engine
|
||||||
|
from dashboard.logging import configure_logging, get_logger
|
||||||
|
from dashboard.providers.registry import ProviderRegistry
|
||||||
|
from dashboard.retention import retention_loop
|
||||||
|
|
||||||
|
logger = get_logger("app")
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||||
|
# React SPA built artifacts. Lives at <repo>/dashboard/web/dist; in the docker
|
||||||
|
# image this is copied to /app/web_dist by the multi-stage Dockerfile.
|
||||||
|
WEB_DIST_DIR = Path("/app/web_dist")
|
||||||
|
if not WEB_DIST_DIR.exists():
|
||||||
|
# Fall back to repo-relative path when running from a checkout (uv run uvicorn ...)
|
||||||
|
WEB_DIST_DIR = Path(__file__).resolve().parents[3] / "web" / "dist"
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||||
|
settings = SettingsCache.get()
|
||||||
|
configure_logging(settings.log_level, settings.log_json)
|
||||||
|
logger.info("Starting Dashboard on port %d", settings.port)
|
||||||
|
|
||||||
|
if settings.staging_mode:
|
||||||
|
logger.warning(
|
||||||
|
"SECURITY: staging_mode=true — ALL authentication/RBAC is bypassed. "
|
||||||
|
"This MUST be disabled in production (set DASHBOARD_STAGING_MODE=false)."
|
||||||
|
)
|
||||||
|
|
||||||
|
init_engine(settings)
|
||||||
|
await create_all_tables()
|
||||||
|
|
||||||
|
# Seed schema from hardcoded KNOWN_KEYS on first run (idempotent — skips if non-empty).
|
||||||
|
from dashboard.api.routes.config import seed_schema_if_empty
|
||||||
|
from dashboard.db.session import get_session
|
||||||
|
async for seed_session in get_session():
|
||||||
|
seeded = await seed_schema_if_empty(seed_session)
|
||||||
|
if seeded:
|
||||||
|
logger.info("config_schema_override seeded with %d entries", seeded)
|
||||||
|
break
|
||||||
|
|
||||||
|
app.state.settings = settings
|
||||||
|
app.state.registry = ProviderRegistry(settings)
|
||||||
|
|
||||||
|
# Background retention task
|
||||||
|
retention_task = asyncio.create_task(retention_loop(settings))
|
||||||
|
|
||||||
|
logger.info("Dashboard started successfully")
|
||||||
|
yield
|
||||||
|
|
||||||
|
retention_task.cancel()
|
||||||
|
with contextlib.suppress(asyncio.CancelledError):
|
||||||
|
await retention_task
|
||||||
|
|
||||||
|
await close_engine()
|
||||||
|
logger.info("Dashboard shutdown complete")
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
settings = SettingsCache.get()
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="Dashboard API",
|
||||||
|
description="Admin dashboard for web search providers and cost tracking",
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
servers=[{"url": settings.external_url, "description": "Dashboard API"}],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Static files (if any) — Tailwind/HTMX come via CDN for zero-build
|
||||||
|
if STATIC_DIR.exists():
|
||||||
|
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||||
|
|
||||||
|
# Prometheus /metrics + OTel tracing (no-op if deps missing)
|
||||||
|
try:
|
||||||
|
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
|
||||||
|
Instrumentator(should_group_status_codes=True).instrument(app).expose(
|
||||||
|
app, endpoint="/metrics", include_in_schema=False
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
import os as _os
|
||||||
|
_otel_ep = _os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||||
|
if _otel_ep:
|
||||||
|
try:
|
||||||
|
from opentelemetry import trace as _trace # type: ignore
|
||||||
|
from opentelemetry.sdk.resources import Resource as _R # type: ignore
|
||||||
|
from opentelemetry.sdk.trace import TracerProvider as _TP # type: ignore
|
||||||
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor as _BSP # type: ignore
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as _Exp # type: ignore
|
||||||
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as _FInst # type: ignore
|
||||||
|
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor as _HXInst # type: ignore
|
||||||
|
_provider = _TP(resource=_R.create({"service.name": _os.environ.get("OTEL_SERVICE_NAME", "didiAI-dashboard")}))
|
||||||
|
_provider.add_span_processor(_BSP(_Exp(endpoint=_otel_ep, insecure=True)))
|
||||||
|
_trace.set_tracer_provider(_provider)
|
||||||
|
_FInst.instrument_app(app)
|
||||||
|
_HXInst().instrument()
|
||||||
|
print(f"[otel] didiAI-dashboard instrumented → {_otel_ep}")
|
||||||
|
except ImportError as _e:
|
||||||
|
print(f"[otel] skip: {_e}")
|
||||||
|
|
||||||
|
# JSON API routes — registered TWICE so they're reachable both at /api/*
|
||||||
|
# (direct access to dashboard:51300) AND at /admin-ai/api/* (when reached
|
||||||
|
# via the admin-dashboard nginx reverse proxy on port 3000). Same handlers,
|
||||||
|
# both URLs Just Work.
|
||||||
|
app.include_router(health.router, tags=["Health"])
|
||||||
|
for prefix in ("/api", "/admin-ai/api"):
|
||||||
|
app.include_router(stats.router, prefix=prefix, tags=["Stats"])
|
||||||
|
app.include_router(history.router, prefix=prefix, tags=["History"])
|
||||||
|
app.include_router(ingest.router, prefix=prefix, tags=["Ingest"])
|
||||||
|
app.include_router(config.router, prefix=prefix, tags=["Config"])
|
||||||
|
app.include_router(archive.router, prefix=prefix, tags=["Archive"])
|
||||||
|
app.include_router(audit.router, prefix=prefix, tags=["Audit"])
|
||||||
|
app.include_router(brain_proxy.router, prefix=prefix, tags=["Brain"])
|
||||||
|
app.include_router(proxy.router, prefix=prefix, tags=["Proxy"])
|
||||||
|
app.include_router(monitoring.router, prefix=prefix, tags=["Monitoring"])
|
||||||
|
app.include_router(catalog.router, prefix=prefix, tags=["Catalog"])
|
||||||
|
|
||||||
|
# HTML pages (Jinja+HTMX legacy — kept until React reaches parity)
|
||||||
|
app.include_router(pages.router, tags=["Pages"])
|
||||||
|
|
||||||
|
# React SPA mounted at /v2/ — runs side-by-side with Jinja during reskin.
|
||||||
|
# StaticFiles(html=True) handles SPA fallback to index.html for client-side routes.
|
||||||
|
if WEB_DIST_DIR.exists():
|
||||||
|
app.mount(
|
||||||
|
"/admin-ai",
|
||||||
|
SPAStaticFiles(directory=str(WEB_DIST_DIR), html=True),
|
||||||
|
name="web-spa",
|
||||||
|
)
|
||||||
|
logger.info("React SPA mounted at /admin-ai from %s", WEB_DIST_DIR)
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
"React dist not found at %s — /admin-ai will return 404. Run `npm run build` in dashboard/web/.",
|
||||||
|
WEB_DIST_DIR,
|
||||||
|
)
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
128
ai_platform/modules/dashboard/src/dashboard/api/dependencies.py
Normal file
128
ai_platform/modules/dashboard/src/dashboard/api/dependencies.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""FastAPI dependencies."""
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from dashboard.api.keycloak_auth import has_required_role, verify_keycloak_jwt
|
||||||
|
from dashboard.auth import find_user_by_token, record_login
|
||||||
|
from dashboard.config import DashboardSettings
|
||||||
|
from dashboard.db.models import User
|
||||||
|
from dashboard.db.session import get_session
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
from dashboard.providers.registry import ProviderRegistry
|
||||||
|
|
||||||
|
logger = get_logger("dependencies")
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings(request: Request) -> DashboardSettings:
|
||||||
|
return request.app.state.settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_registry(request: Request) -> ProviderRegistry:
|
||||||
|
return request.app.state.registry
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_bearer_token(
|
||||||
|
request: Request,
|
||||||
|
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> User | str | dict[str, Any] | None:
|
||||||
|
"""Resolve auth — Keycloak JWT first, legacy bearer second, anonymous last.
|
||||||
|
|
||||||
|
Resolution order:
|
||||||
|
0. If `staging_mode=true`, return a synthetic "staging" principal
|
||||||
|
1. If `keycloak_url` is set AND a Bearer token is provided, try JWT verification.
|
||||||
|
On success, the principal is the JWT payload dict.
|
||||||
|
2. Else if a Users row matches the token, return that User
|
||||||
|
3. Else if the token is in settings.api_tokens (legacy), return the raw string
|
||||||
|
4. Else (no auth configured at all): return None (endpoint open)
|
||||||
|
5. Else raise 401
|
||||||
|
"""
|
||||||
|
settings: DashboardSettings = request.app.state.settings
|
||||||
|
|
||||||
|
if settings.staging_mode:
|
||||||
|
return {"_staging": True, "username": "staging-mode", "_roles": []}
|
||||||
|
|
||||||
|
keycloak_enabled = bool(settings.keycloak_url)
|
||||||
|
|
||||||
|
# Snapshot if there's any local auth configured (for fallback path)
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
user_count = (await session.execute(select(func.count(User.id)))).scalar() or 0
|
||||||
|
has_legacy_tokens = bool(settings.api_tokens)
|
||||||
|
|
||||||
|
auth_configured = keycloak_enabled or user_count > 0 or has_legacy_tokens
|
||||||
|
if not auth_configured:
|
||||||
|
return None # Auth disabled
|
||||||
|
|
||||||
|
if not authorization:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=401,
|
||||||
|
detail={"error": "Authentication required"},
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
parts = authorization.split()
|
||||||
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
|
raise HTTPException(status_code=401, detail={"error": "Invalid Authorization"})
|
||||||
|
|
||||||
|
token = parts[1]
|
||||||
|
|
||||||
|
# 1) Try Keycloak JWT — JWTs are typically much longer than DB tokens
|
||||||
|
# so we attempt this first when Keycloak is configured.
|
||||||
|
if keycloak_enabled and token.count(".") == 2: # JWTs have 3 parts (header.payload.sig)
|
||||||
|
try:
|
||||||
|
payload = await verify_keycloak_jwt(token, settings)
|
||||||
|
if not has_required_role(payload, settings.keycloak_required_role):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail={
|
||||||
|
"error": "Forbidden",
|
||||||
|
"required_role": settings.keycloak_required_role,
|
||||||
|
"user_roles": payload.get("_roles", []),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return payload
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
# Not a valid JWT — fall through to local auth methods
|
||||||
|
logger.debug("JWT path failed, falling back: %s", e)
|
||||||
|
|
||||||
|
# 2) DB-backed users
|
||||||
|
if user_count > 0:
|
||||||
|
user = await find_user_by_token(session, token)
|
||||||
|
if user is not None:
|
||||||
|
await record_login(session, user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
# 3) Legacy static tokens
|
||||||
|
if has_legacy_tokens:
|
||||||
|
is_valid = any(
|
||||||
|
hmac.compare_digest(token.encode(), valid.encode())
|
||||||
|
for valid in settings.api_tokens
|
||||||
|
)
|
||||||
|
if is_valid:
|
||||||
|
return token
|
||||||
|
|
||||||
|
raise HTTPException(status_code=401, detail={"error": "Invalid token"})
|
||||||
|
|
||||||
|
|
||||||
|
def get_username(principal: User | str | dict[str, Any] | None) -> str:
|
||||||
|
"""Extract a display name for audit logs from the auth result."""
|
||||||
|
if principal is None:
|
||||||
|
return "anonymous"
|
||||||
|
if isinstance(principal, User):
|
||||||
|
return principal.username
|
||||||
|
if isinstance(principal, dict):
|
||||||
|
# Keycloak JWT payload — prefer email, then preferred_username, then sub
|
||||||
|
return (
|
||||||
|
principal.get("email")
|
||||||
|
or principal.get("preferred_username")
|
||||||
|
or principal.get("sub")
|
||||||
|
or "keycloak-user"
|
||||||
|
)
|
||||||
|
return "legacy-token"
|
||||||
128
ai_platform/modules/dashboard/src/dashboard/api/keycloak_auth.py
Normal file
128
ai_platform/modules/dashboard/src/dashboard/api/keycloak_auth.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""Keycloak JWT verification for the AI platform dashboard.
|
||||||
|
|
||||||
|
We do NOT proxy /auth/ here — the React SPA hits Keycloak directly at
|
||||||
|
`{keycloak_url}/realms/{realm}/...`. Backend's only job is to validate
|
||||||
|
incoming Bearer JWTs against the realm's JWKS and check the required role.
|
||||||
|
|
||||||
|
JWKS is cached for 10 min. On signature failure (e.g., key rotation) the
|
||||||
|
cache is invalidated and refetched once.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from jose import jwt
|
||||||
|
from jose.exceptions import ExpiredSignatureError, JWTError
|
||||||
|
|
||||||
|
from dashboard.config import DashboardSettings
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("keycloak_auth")
|
||||||
|
|
||||||
|
_JWKS_CACHE: dict[str, Any] = {"keys": None, "fetched_at": 0.0, "url": ""}
|
||||||
|
_JWKS_TTL_SECONDS = 600 # 10 min
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_jwks(url: str) -> dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_jwks(settings: DashboardSettings, force: bool = False) -> dict[str, Any]:
|
||||||
|
"""Return JWKS for the configured realm, with TTL cache."""
|
||||||
|
if not settings.keycloak_url:
|
||||||
|
raise RuntimeError("Keycloak URL not configured")
|
||||||
|
url = (
|
||||||
|
f"{settings.keycloak_url.rstrip('/')}/realms/"
|
||||||
|
f"{settings.keycloak_realm}/protocol/openid-connect/certs"
|
||||||
|
)
|
||||||
|
now = time.time()
|
||||||
|
cached_for_url = _JWKS_CACHE["url"] == url
|
||||||
|
fresh = cached_for_url and (now - _JWKS_CACHE["fetched_at"]) < _JWKS_TTL_SECONDS
|
||||||
|
if not force and fresh and _JWKS_CACHE["keys"] is not None:
|
||||||
|
return _JWKS_CACHE["keys"]
|
||||||
|
|
||||||
|
keys = await _fetch_jwks(url)
|
||||||
|
_JWKS_CACHE["keys"] = keys
|
||||||
|
_JWKS_CACHE["fetched_at"] = now
|
||||||
|
_JWKS_CACHE["url"] = url
|
||||||
|
logger.info(
|
||||||
|
"JWKS refreshed (realm=%s, keys=%d)",
|
||||||
|
settings.keycloak_realm,
|
||||||
|
len(keys.get("keys", [])),
|
||||||
|
)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def _expected_issuer(settings: DashboardSettings) -> str:
|
||||||
|
return f"{settings.keycloak_url.rstrip('/')}/realms/{settings.keycloak_realm}"
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_roles(payload: dict[str, Any]) -> list[str]:
|
||||||
|
"""Pick up realm roles + client roles for the configured client."""
|
||||||
|
roles: list[str] = []
|
||||||
|
realm_access = payload.get("realm_access") or {}
|
||||||
|
if isinstance(realm_access, dict):
|
||||||
|
roles.extend(realm_access.get("roles") or [])
|
||||||
|
resource_access = payload.get("resource_access") or {}
|
||||||
|
if isinstance(resource_access, dict):
|
||||||
|
for client_block in resource_access.values():
|
||||||
|
if isinstance(client_block, dict):
|
||||||
|
roles.extend(client_block.get("roles") or [])
|
||||||
|
return roles
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_keycloak_jwt(
|
||||||
|
token: str,
|
||||||
|
settings: DashboardSettings,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Verify a Keycloak JWT against JWKS. Raises HTTPException on failure.
|
||||||
|
|
||||||
|
Returns the decoded payload + a synthetic `_roles` list flattened from
|
||||||
|
realm_access + resource_access. Audience is NOT enforced — Keycloak
|
||||||
|
issues tokens with audience='account' by default; instead we verify the
|
||||||
|
issuer + signature + exp + presence of the required role.
|
||||||
|
"""
|
||||||
|
if not settings.keycloak_url:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Keycloak not configured",
|
||||||
|
)
|
||||||
|
|
||||||
|
issuer = _expected_issuer(settings)
|
||||||
|
|
||||||
|
async def _decode_with_keys(force_refresh: bool) -> dict[str, Any]:
|
||||||
|
jwks = await _get_jwks(settings, force=force_refresh)
|
||||||
|
try:
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
jwks,
|
||||||
|
algorithms=["RS256"],
|
||||||
|
issuer=issuer,
|
||||||
|
options={"verify_aud": False},
|
||||||
|
)
|
||||||
|
except ExpiredSignatureError:
|
||||||
|
raise HTTPException(status_code=401, detail="Token expired")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = await _decode_with_keys(force_refresh=False)
|
||||||
|
except JWTError:
|
||||||
|
# Try once with fresh JWKS in case keys rotated
|
||||||
|
try:
|
||||||
|
payload = await _decode_with_keys(force_refresh=True)
|
||||||
|
except JWTError as e:
|
||||||
|
logger.warning("JWT verify failed after JWKS refresh: %s", e)
|
||||||
|
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
|
||||||
|
|
||||||
|
payload["_roles"] = _extract_roles(payload)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def has_required_role(payload: dict[str, Any], required_role: str) -> bool:
|
||||||
|
return required_role in (payload.get("_roles") or [])
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""API routes."""
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
"""Claims & articles archive — permanent storage for valuable gather results.
|
||||||
|
|
||||||
|
Promoting a request_history row extracts its claim and evidence items and
|
||||||
|
stores them in claims_archive + articles_archive. These tables will later
|
||||||
|
be consumed by a dedicated claims-api that serves archived claims through
|
||||||
|
the same /v1/gather response schema as web-api — no network search required.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import desc, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from dashboard.api.dependencies import get_username, verify_bearer_token
|
||||||
|
from dashboard.db.models import (
|
||||||
|
ArticlesArchive,
|
||||||
|
ClaimArticle,
|
||||||
|
ClaimsArchive,
|
||||||
|
RequestHistory,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from dashboard.db.session import get_session
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("archive")
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _sha256(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Read endpoints (public — will be consumed by claims-api too)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/archive/claims")
|
||||||
|
async def list_claims(
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
q: str | None = Query(default=None, description="Case-insensitive claim search"),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List archived claims with optional search."""
|
||||||
|
stmt = select(ClaimsArchive)
|
||||||
|
if q:
|
||||||
|
stmt = stmt.where(ClaimsArchive.claim.ilike(f"%{q}%"))
|
||||||
|
stmt = stmt.order_by(desc(ClaimsArchive.created_at)).limit(limit).offset(offset)
|
||||||
|
|
||||||
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
|
|
||||||
|
count_stmt = select(func.count(ClaimsArchive.id))
|
||||||
|
if q:
|
||||||
|
count_stmt = count_stmt.where(ClaimsArchive.claim.ilike(f"%{q}%"))
|
||||||
|
total = (await session.execute(count_stmt)).scalar() or 0
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"claim": r.claim,
|
||||||
|
"verdict": r.verdict,
|
||||||
|
"confidence": float(r.confidence) if r.confidence is not None else None,
|
||||||
|
"summary": r.summary,
|
||||||
|
"primary_country": r.primary_country,
|
||||||
|
"detected_language": r.detected_language,
|
||||||
|
"promoted_by": r.promoted_by,
|
||||||
|
"tags": r.tags,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
return {"items": items, "total": total, "offset": offset, "limit": limit}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/archive/claims/{claim_id}")
|
||||||
|
async def get_claim(
|
||||||
|
claim_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Get a single claim with its linked articles."""
|
||||||
|
claim = (
|
||||||
|
await session.execute(select(ClaimsArchive).where(ClaimsArchive.id == claim_id))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if claim is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Claim not found")
|
||||||
|
|
||||||
|
# Load linked articles
|
||||||
|
links_stmt = (
|
||||||
|
select(ClaimArticle, ArticlesArchive)
|
||||||
|
.join(ArticlesArchive, ClaimArticle.article_id == ArticlesArchive.id)
|
||||||
|
.where(ClaimArticle.claim_id == claim_id)
|
||||||
|
)
|
||||||
|
links = (await session.execute(links_stmt)).all()
|
||||||
|
|
||||||
|
articles = [
|
||||||
|
{
|
||||||
|
"id": article.id,
|
||||||
|
"url": article.url,
|
||||||
|
"title": article.title,
|
||||||
|
"publisher": article.publisher,
|
||||||
|
"published_at": article.published_at.isoformat()
|
||||||
|
if article.published_at
|
||||||
|
else None,
|
||||||
|
"retrieved_at": article.retrieved_at.isoformat()
|
||||||
|
if article.retrieved_at
|
||||||
|
else None,
|
||||||
|
"credibility_score": float(article.credibility_score)
|
||||||
|
if article.credibility_score is not None
|
||||||
|
else None,
|
||||||
|
"relevance_score": float(link.relevance_score)
|
||||||
|
if link.relevance_score is not None
|
||||||
|
else None,
|
||||||
|
"snippet": link.snippet,
|
||||||
|
"full_text": article.full_text,
|
||||||
|
}
|
||||||
|
for link, article in links
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": claim.id,
|
||||||
|
"claim": claim.claim,
|
||||||
|
"verdict": claim.verdict,
|
||||||
|
"confidence": float(claim.confidence) if claim.confidence is not None else None,
|
||||||
|
"summary": claim.summary,
|
||||||
|
"primary_country": claim.primary_country,
|
||||||
|
"detected_language": claim.detected_language,
|
||||||
|
"entities": claim.entities,
|
||||||
|
"promoted_by": claim.promoted_by,
|
||||||
|
"tags": claim.tags,
|
||||||
|
"created_at": claim.created_at.isoformat() if claim.created_at else None,
|
||||||
|
"source_request_id": claim.source_request_id,
|
||||||
|
"articles": articles,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Write endpoints (auth required)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/archive/promote/{request_id}")
|
||||||
|
async def promote_request(
|
||||||
|
request_id: str,
|
||||||
|
principal: User | str | None = Depends(verify_bearer_token),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Promote a request_history row into the permanent archive.
|
||||||
|
|
||||||
|
Extracts the claim, search_context and evidence items from the
|
||||||
|
stored raw_response and creates/updates the corresponding rows.
|
||||||
|
"""
|
||||||
|
row = (
|
||||||
|
await session.execute(
|
||||||
|
select(RequestHistory).where(RequestHistory.request_id == request_id)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Request not found")
|
||||||
|
|
||||||
|
if row.endpoint != "/v1/gather":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Only /v1/gather requests can be promoted",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not row.raw_response:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Request has no stored response (too large or not a success)",
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = row.raw_response
|
||||||
|
claim_text = raw.get("claim") or row.claim
|
||||||
|
if not claim_text:
|
||||||
|
raise HTTPException(status_code=400, detail="Missing claim text")
|
||||||
|
|
||||||
|
claim_hash = _sha256(claim_text.strip().lower())
|
||||||
|
context = raw.get("search_context") or {}
|
||||||
|
evidence_items = raw.get("evidence") or []
|
||||||
|
|
||||||
|
# Upsert claim
|
||||||
|
existing_claim = (
|
||||||
|
await session.execute(
|
||||||
|
select(ClaimsArchive).where(ClaimsArchive.claim_hash == claim_hash)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
username = get_username(principal)
|
||||||
|
|
||||||
|
if existing_claim is None:
|
||||||
|
claim_row = ClaimsArchive(
|
||||||
|
claim=claim_text,
|
||||||
|
claim_hash=claim_hash,
|
||||||
|
source_request_id=request_id,
|
||||||
|
primary_country=context.get("primary_country"),
|
||||||
|
detected_language=context.get("detected_language"),
|
||||||
|
entities=context.get("entities"),
|
||||||
|
promoted_by=username,
|
||||||
|
tags=[],
|
||||||
|
summary=_build_auto_summary(evidence_items),
|
||||||
|
)
|
||||||
|
session.add(claim_row)
|
||||||
|
await session.flush() # need the id for the link rows
|
||||||
|
else:
|
||||||
|
claim_row = existing_claim
|
||||||
|
# Refresh metadata on re-promotion
|
||||||
|
claim_row.source_request_id = request_id
|
||||||
|
claim_row.promoted_by = username
|
||||||
|
claim_row.primary_country = context.get("primary_country") or claim_row.primary_country
|
||||||
|
claim_row.detected_language = (
|
||||||
|
context.get("detected_language") or claim_row.detected_language
|
||||||
|
)
|
||||||
|
claim_row.entities = context.get("entities") or claim_row.entities
|
||||||
|
|
||||||
|
# Upsert articles + links
|
||||||
|
created_articles = 0
|
||||||
|
linked_articles = 0
|
||||||
|
for item in evidence_items:
|
||||||
|
url = item.get("url")
|
||||||
|
if not url:
|
||||||
|
continue
|
||||||
|
url_hash = _sha256(url)
|
||||||
|
|
||||||
|
existing_article = (
|
||||||
|
await session.execute(
|
||||||
|
select(ArticlesArchive).where(ArticlesArchive.url_hash == url_hash)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
published_at = _parse_datetime(item.get("published_at"))
|
||||||
|
retrieved_at = _parse_datetime(item.get("retrieved_at")) or datetime.now(
|
||||||
|
timezone.utc
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_article is None:
|
||||||
|
article_row = ArticlesArchive(
|
||||||
|
url=url,
|
||||||
|
url_hash=url_hash,
|
||||||
|
canonical_url=item.get("canonical_url"),
|
||||||
|
title=item.get("title"),
|
||||||
|
full_text=item.get("full_text"),
|
||||||
|
publisher=item.get("publisher"),
|
||||||
|
published_at=published_at,
|
||||||
|
retrieved_at=retrieved_at,
|
||||||
|
extraction_method=(item.get("provenance") or {}).get(
|
||||||
|
"extraction_method"
|
||||||
|
),
|
||||||
|
credibility_score=item.get("credibility_score"),
|
||||||
|
)
|
||||||
|
session.add(article_row)
|
||||||
|
await session.flush()
|
||||||
|
created_articles += 1
|
||||||
|
else:
|
||||||
|
article_row = existing_article
|
||||||
|
if item.get("full_text") and not article_row.full_text:
|
||||||
|
article_row.full_text = item["full_text"]
|
||||||
|
|
||||||
|
# Link claim ↔ article
|
||||||
|
existing_link = (
|
||||||
|
await session.execute(
|
||||||
|
select(ClaimArticle).where(
|
||||||
|
ClaimArticle.claim_id == claim_row.id,
|
||||||
|
ClaimArticle.article_id == article_row.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing_link is None:
|
||||||
|
session.add(
|
||||||
|
ClaimArticle(
|
||||||
|
claim_id=claim_row.id,
|
||||||
|
article_id=article_row.id,
|
||||||
|
relevance_score=item.get("relevance_score"),
|
||||||
|
snippet=item.get("snippet"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
linked_articles += 1
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Promoted %s: claim_id=%d articles+%d links+%d by=%s",
|
||||||
|
request_id,
|
||||||
|
claim_row.id,
|
||||||
|
created_articles,
|
||||||
|
linked_articles,
|
||||||
|
username,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"claim_id": claim_row.id,
|
||||||
|
"claim_hash": claim_hash,
|
||||||
|
"articles_created": created_articles,
|
||||||
|
"articles_linked": linked_articles,
|
||||||
|
"promoted_by": username,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
def _build_auto_summary(evidence_items: list[dict]) -> str | None:
|
||||||
|
"""Build a basic auto-summary from evidence titles when no LLM summary exists."""
|
||||||
|
if not evidence_items:
|
||||||
|
return None
|
||||||
|
titles = [e.get("title") for e in evidence_items if e.get("title")]
|
||||||
|
if not titles:
|
||||||
|
return None
|
||||||
|
return " · ".join(titles[:3])[:800]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_datetime(value: Any) -> datetime | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Audit log endpoint — chronological mutation history."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import desc, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from dashboard.api.dependencies import verify_bearer_token
|
||||||
|
from dashboard.db.models import AuditLog
|
||||||
|
from dashboard.db.session import get_session
|
||||||
|
|
||||||
|
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||||
|
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit")
|
||||||
|
async def list_audit(
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
username: str | None = Query(default=None),
|
||||||
|
action: str | None = Query(default=None),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Paginated audit log entries, newest first."""
|
||||||
|
where_clauses = []
|
||||||
|
if username:
|
||||||
|
where_clauses.append(AuditLog.username == username)
|
||||||
|
if action:
|
||||||
|
where_clauses.append(AuditLog.action == action)
|
||||||
|
|
||||||
|
base = select(AuditLog)
|
||||||
|
if where_clauses:
|
||||||
|
for c in where_clauses:
|
||||||
|
base = base.where(c)
|
||||||
|
|
||||||
|
# Total
|
||||||
|
from sqlalchemy import func
|
||||||
|
|
||||||
|
count_q = select(func.count(AuditLog.id))
|
||||||
|
if where_clauses:
|
||||||
|
for c in where_clauses:
|
||||||
|
count_q = count_q.where(c)
|
||||||
|
total = (await session.execute(count_q)).scalar() or 0
|
||||||
|
|
||||||
|
rows = (
|
||||||
|
await session.execute(
|
||||||
|
base.order_by(desc(AuditLog.timestamp)).limit(limit).offset(offset)
|
||||||
|
)
|
||||||
|
).scalars().all()
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"timestamp": r.timestamp.isoformat() if r.timestamp else None,
|
||||||
|
"username": r.username,
|
||||||
|
"action": r.action,
|
||||||
|
"target": r.target,
|
||||||
|
"old_value": r.old_value,
|
||||||
|
"new_value": r.new_value,
|
||||||
|
}
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""Thin proxy from /api/brain/* → didibrain-api.
|
||||||
|
|
||||||
|
Why a proxy: the React SPA is served from dashboard:51300 and the brain admin
|
||||||
|
endpoints live at brain:8090. Calling brain directly from the browser would
|
||||||
|
require CORS + separate base URL. Easier to forward through dashboard FastAPI
|
||||||
|
— same origin, same auth surface.
|
||||||
|
|
||||||
|
Auth model: GET is read-only admin behind the VPN (no token). Mutations
|
||||||
|
(POST/PATCH/DELETE) require dashboard bearer token via verify_bearer_token,
|
||||||
|
matching /api/config.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||||
|
|
||||||
|
from dashboard.api.dependencies import verify_bearer_token
|
||||||
|
from dashboard.config import DashboardSettings, SettingsCache
|
||||||
|
from dashboard.db.models import User
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("brain_proxy")
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# Whitelist of admin endpoints exposed by brain through this proxy.
|
||||||
|
# We do NOT expose /v1/analysis_atom/lookup, POST /v1/analysis_atom (the write
|
||||||
|
# endpoint), or PATCH for force-gold via the proxy — those are agent-v3 only,
|
||||||
|
# direct path. The proxy is only for dashboard-driven admin browsing.
|
||||||
|
_ALLOWED_PREFIXES = (
|
||||||
|
"/v1/analysis_atom/list",
|
||||||
|
"/v1/analysis_atom/stats",
|
||||||
|
"/v1/analysis_atom/", # GET / DELETE individual atom
|
||||||
|
"/v1/verification_cache/list",
|
||||||
|
"/v1/verification_cache/", # GET / DELETE individual cache row
|
||||||
|
# Phase D2 — fact status admin browser, audit log, cache ops
|
||||||
|
"/v1/fact_status/list",
|
||||||
|
"/v1/fact_status/", # GET / PATCH individual fact + /:id/versions
|
||||||
|
"/v1/cache/audit_log",
|
||||||
|
"/v1/cache/invalidate",
|
||||||
|
# Phase D2 — temporal canonicalize (admin can preview rewrite)
|
||||||
|
"/v1/canonicalize",
|
||||||
|
"/v1/taxonomy",
|
||||||
|
"/v1/taxonomy/reload",
|
||||||
|
"/health",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_allowed(path: str) -> bool:
|
||||||
|
return any(path == p or path.startswith(p) for p in _ALLOWED_PREFIXES)
|
||||||
|
|
||||||
|
|
||||||
|
def _settings() -> DashboardSettings:
|
||||||
|
return SettingsCache.get()
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route(
|
||||||
|
"/brain/{full_path:path}",
|
||||||
|
methods=["GET"],
|
||||||
|
tags=["Brain"],
|
||||||
|
)
|
||||||
|
async def brain_proxy_read(full_path: str, request: Request) -> Response:
|
||||||
|
"""Read-only proxy /api/brain/<path> → {brain_url}/<path>."""
|
||||||
|
return await _do_proxy(full_path, request)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route(
|
||||||
|
"/brain/{full_path:path}",
|
||||||
|
methods=["POST", "PATCH", "DELETE"],
|
||||||
|
tags=["Brain"],
|
||||||
|
)
|
||||||
|
async def brain_proxy_mutate(
|
||||||
|
full_path: str,
|
||||||
|
request: Request,
|
||||||
|
_principal: User | str | None = Depends(verify_bearer_token),
|
||||||
|
) -> Response:
|
||||||
|
"""Mutating proxy — requires bearer token (same as /api/config mutations)."""
|
||||||
|
return await _do_proxy(full_path, request)
|
||||||
|
|
||||||
|
|
||||||
|
async def _do_proxy(full_path: str, request: Request) -> Response:
|
||||||
|
settings = _settings()
|
||||||
|
target_path = "/" + full_path.lstrip("/")
|
||||||
|
|
||||||
|
if not _is_allowed(target_path):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Not a proxied path: {target_path}")
|
||||||
|
|
||||||
|
method = request.method.upper()
|
||||||
|
|
||||||
|
upstream = settings.brain_url.rstrip("/") + target_path
|
||||||
|
body = await request.body()
|
||||||
|
fwd_headers = {
|
||||||
|
k: v
|
||||||
|
for k, v in request.headers.items()
|
||||||
|
if k.lower() in ("content-type", "accept", "x-request-id")
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
try:
|
||||||
|
r = await client.request(
|
||||||
|
method=method,
|
||||||
|
url=upstream,
|
||||||
|
params=dict(request.query_params),
|
||||||
|
content=body if body else None,
|
||||||
|
headers=fwd_headers,
|
||||||
|
)
|
||||||
|
except httpx.TimeoutException as e:
|
||||||
|
logger.warning("brain_proxy_timeout path=%s err=%s", target_path, e)
|
||||||
|
raise HTTPException(status_code=504, detail="brain upstream timeout")
|
||||||
|
except httpx.HTTPError as e:
|
||||||
|
logger.warning("brain_proxy_error path=%s err=%s", target_path, e)
|
||||||
|
raise HTTPException(status_code=502, detail=f"brain upstream error: {e}")
|
||||||
|
|
||||||
|
pass_headers = {
|
||||||
|
k: v
|
||||||
|
for k, v in r.headers.items()
|
||||||
|
if k.lower() not in ("transfer-encoding", "content-encoding", "connection")
|
||||||
|
}
|
||||||
|
return Response(
|
||||||
|
content=r.content,
|
||||||
|
status_code=r.status_code,
|
||||||
|
headers=pass_headers,
|
||||||
|
media_type=r.headers.get("content-type"),
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,194 @@
|
||||||
|
"""DB-backed catalog of AI models / extractors with CRUD (Val 2).
|
||||||
|
|
||||||
|
Satisfies the caiet requirement to administer models/extractors "conform datelor
|
||||||
|
din baza de date". List is read-only; create/update/delete require auth and are
|
||||||
|
audit-logged (same pattern as config mutations).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from dashboard.api.dependencies import get_username, verify_bearer_token
|
||||||
|
from dashboard.db.models import AuditLog, CatalogEntry, User
|
||||||
|
from dashboard.db.session import get_session
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("catalog")
|
||||||
|
# RBAC: protected when auth is configured; mutations additionally resolve the
|
||||||
|
# principal for audit. Open in dev when no auth is configured.
|
||||||
|
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||||
|
|
||||||
|
_KINDS = {"model", "extractor"}
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogIn(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
kind: str = Field(default="model", description="model | extractor")
|
||||||
|
name: str = Field(..., min_length=1, max_length=128)
|
||||||
|
display_name: str | None = None
|
||||||
|
service: str = Field(..., min_length=1, max_length=32)
|
||||||
|
capabilities: Any | None = None
|
||||||
|
context_length: int | None = Field(default=None, ge=0)
|
||||||
|
supports_cpu: bool = False
|
||||||
|
supports_gpu: bool = False
|
||||||
|
quantization: str | None = None
|
||||||
|
endpoint: str | None = None
|
||||||
|
limits: Any | None = None
|
||||||
|
cost_per_token: float | None = Field(default=None, ge=0)
|
||||||
|
enabled: bool = True
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogPatch(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
display_name: str | None = None
|
||||||
|
capabilities: Any | None = None
|
||||||
|
context_length: int | None = Field(default=None, ge=0)
|
||||||
|
supports_cpu: bool | None = None
|
||||||
|
supports_gpu: bool | None = None
|
||||||
|
quantization: str | None = None
|
||||||
|
endpoint: str | None = None
|
||||||
|
limits: Any | None = None
|
||||||
|
cost_per_token: float | None = Field(default=None, ge=0)
|
||||||
|
enabled: bool | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize(e: CatalogEntry) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": e.id,
|
||||||
|
"kind": e.kind,
|
||||||
|
"name": e.name,
|
||||||
|
"display_name": e.display_name,
|
||||||
|
"service": e.service,
|
||||||
|
"capabilities": e.capabilities,
|
||||||
|
"context_length": e.context_length,
|
||||||
|
"supports_cpu": e.supports_cpu,
|
||||||
|
"supports_gpu": e.supports_gpu,
|
||||||
|
"quantization": e.quantization,
|
||||||
|
"endpoint": e.endpoint,
|
||||||
|
"limits": e.limits,
|
||||||
|
"cost_per_token": float(e.cost_per_token) if e.cost_per_token is not None else None,
|
||||||
|
"enabled": e.enabled,
|
||||||
|
"notes": e.notes,
|
||||||
|
"updated_at": e.updated_at.isoformat() if e.updated_at else None,
|
||||||
|
"updated_by": e.updated_by,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog")
|
||||||
|
async def list_catalog(
|
||||||
|
kind: str | None = None,
|
||||||
|
service: str | None = None,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""List catalog entries, optionally filtered by kind/service."""
|
||||||
|
|
||||||
|
stmt = select(CatalogEntry)
|
||||||
|
if kind:
|
||||||
|
stmt = stmt.where(CatalogEntry.kind == kind)
|
||||||
|
if service:
|
||||||
|
stmt = stmt.where(CatalogEntry.service == service)
|
||||||
|
stmt = stmt.order_by(CatalogEntry.service, CatalogEntry.name)
|
||||||
|
rows = (await session.execute(stmt)).scalars().all()
|
||||||
|
return {"items": [_serialize(r) for r in rows], "total": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/catalog")
|
||||||
|
async def create_catalog(
|
||||||
|
body: CatalogIn,
|
||||||
|
principal: User | str | None = Depends(verify_bearer_token),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a catalog entry (auth + audit)."""
|
||||||
|
|
||||||
|
if body.kind not in _KINDS:
|
||||||
|
raise HTTPException(status_code=422, detail=f"kind must be one of {_KINDS}")
|
||||||
|
dup = (
|
||||||
|
await session.execute(
|
||||||
|
select(CatalogEntry).where(
|
||||||
|
CatalogEntry.service == body.service, CatalogEntry.name == body.name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if dup is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409, detail=f"{body.service}/{body.name} already exists"
|
||||||
|
)
|
||||||
|
|
||||||
|
username = get_username(principal)
|
||||||
|
entry = CatalogEntry(**body.model_dump(), updated_by=username)
|
||||||
|
session.add(entry)
|
||||||
|
session.add(
|
||||||
|
AuditLog(username=username, action="catalog.create",
|
||||||
|
target=f"{body.service}/{body.name}",
|
||||||
|
old_value={}, new_value=body.model_dump())
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(entry)
|
||||||
|
logger.info("Catalog entry %s/%s created by %s", body.service, body.name, username)
|
||||||
|
return _serialize(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/catalog/{entry_id}")
|
||||||
|
async def update_catalog(
|
||||||
|
entry_id: int,
|
||||||
|
body: CatalogPatch,
|
||||||
|
principal: User | str | None = Depends(verify_bearer_token),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Update mutable fields of a catalog entry (auth + audit)."""
|
||||||
|
|
||||||
|
entry = await session.get(CatalogEntry, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail="catalog entry not found")
|
||||||
|
|
||||||
|
changes = body.model_dump(exclude_unset=True)
|
||||||
|
if not changes:
|
||||||
|
raise HTTPException(status_code=422, detail="no fields to update")
|
||||||
|
old = _serialize(entry)
|
||||||
|
for field, value in changes.items():
|
||||||
|
setattr(entry, field, value)
|
||||||
|
entry.updated_at = datetime.now(timezone.utc)
|
||||||
|
username = get_username(principal)
|
||||||
|
entry.updated_by = username
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
AuditLog(username=username, action="catalog.update",
|
||||||
|
target=f"{entry.service}/{entry.name}", old_value=old, new_value=changes)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(entry)
|
||||||
|
return _serialize(entry)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/catalog/{entry_id}")
|
||||||
|
async def delete_catalog(
|
||||||
|
entry_id: int,
|
||||||
|
principal: User | str | None = Depends(verify_bearer_token),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Delete a catalog entry (auth + audit)."""
|
||||||
|
|
||||||
|
entry = await session.get(CatalogEntry, entry_id)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=404, detail="catalog entry not found")
|
||||||
|
target = f"{entry.service}/{entry.name}"
|
||||||
|
old = _serialize(entry)
|
||||||
|
username = get_username(principal)
|
||||||
|
await session.delete(entry)
|
||||||
|
session.add(
|
||||||
|
AuditLog(username=username, action="catalog.delete", target=target,
|
||||||
|
old_value=old, new_value={})
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return {"deleted": entry_id, "target": target}
|
||||||
1636
ai_platform/modules/dashboard/src/dashboard/api/routes/config.py
Normal file
1636
ai_platform/modules/dashboard/src/dashboard/api/routes/config.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Health endpoints."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from dashboard.db.session import get_session_factory
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
async def health() -> dict:
|
||||||
|
try:
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as session:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
db_ok = True
|
||||||
|
except Exception as e:
|
||||||
|
return {"status": "degraded", "db": False, "error": str(e)}
|
||||||
|
return {"status": "healthy", "db": db_ok}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ready")
|
||||||
|
async def ready(request: Request) -> dict:
|
||||||
|
return {
|
||||||
|
"ready": hasattr(request.app.state, "settings")
|
||||||
|
and hasattr(request.app.state, "registry")
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
"""Request history endpoints."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import desc, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from dashboard.api.dependencies import verify_bearer_token
|
||||||
|
from dashboard.db.models import RequestHistory
|
||||||
|
from dashboard.db.session import get_session
|
||||||
|
|
||||||
|
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||||
|
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def list_history(
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
module: str | None = Query(default=None),
|
||||||
|
tier: str | None = Query(default=None),
|
||||||
|
provider: str | None = Query(default=None),
|
||||||
|
endpoint: str | None = Query(default=None),
|
||||||
|
hours: int = Query(default=168, ge=1, le=720), # default 7 days
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""List recent requests with filters."""
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
q = select(RequestHistory).where(RequestHistory.created_at >= since)
|
||||||
|
if module:
|
||||||
|
q = q.where(RequestHistory.module == module)
|
||||||
|
if tier:
|
||||||
|
q = q.where(RequestHistory.tier == tier)
|
||||||
|
if provider:
|
||||||
|
q = q.where(RequestHistory.provider == provider)
|
||||||
|
if endpoint:
|
||||||
|
q = q.where(RequestHistory.endpoint == endpoint)
|
||||||
|
q = q.order_by(desc(RequestHistory.created_at)).limit(limit).offset(offset)
|
||||||
|
|
||||||
|
rows = (await session.execute(q)).scalars().all()
|
||||||
|
items = [_row_to_dict(r) for r in rows]
|
||||||
|
return {"items": items, "count": len(items), "offset": offset}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/{key}")
|
||||||
|
async def get_history(
|
||||||
|
key: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Get one request by either numeric id (UI uses this) or request_id string."""
|
||||||
|
# Numeric path → row primary key. String path → request_id (for service-to-service
|
||||||
|
# tooling that already knows the UUID it sent on POST /api/ingest/event).
|
||||||
|
try:
|
||||||
|
numeric_id = int(key)
|
||||||
|
q = select(RequestHistory).where(RequestHistory.id == numeric_id)
|
||||||
|
except ValueError:
|
||||||
|
q = select(RequestHistory).where(RequestHistory.request_id == key)
|
||||||
|
row = (await session.execute(q)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
return _row_to_dict(row, full=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_dict(row: RequestHistory, full: bool = False) -> dict:
|
||||||
|
out = {
|
||||||
|
"id": row.id,
|
||||||
|
"request_id": row.request_id,
|
||||||
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
|
"module": row.module,
|
||||||
|
"tier": row.tier,
|
||||||
|
"endpoint": row.endpoint,
|
||||||
|
"provider": row.provider,
|
||||||
|
"claim": row.claim,
|
||||||
|
"query": row.query,
|
||||||
|
"duration_ms": row.duration_ms,
|
||||||
|
"status_code": row.status_code,
|
||||||
|
"results_count": row.results_count,
|
||||||
|
"evidence_count": row.evidence_count,
|
||||||
|
"error": row.error,
|
||||||
|
"cost_usd": float(row.cost_usd) if row.cost_usd is not None else None,
|
||||||
|
"user_id": row.user_id,
|
||||||
|
}
|
||||||
|
if full:
|
||||||
|
out["stages"] = row.stages
|
||||||
|
out["raw_request"] = row.raw_request
|
||||||
|
out["raw_response"] = row.raw_response
|
||||||
|
return out
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
"""Event ingest endpoint — receives request events from web-api."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from dashboard.db.models import RequestHistory
|
||||||
|
from dashboard.db.session import get_session
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
from dashboard.pricing import estimate_cost
|
||||||
|
|
||||||
|
logger = get_logger("ingest")
|
||||||
|
|
||||||
|
# Service-to-service ingest — no auth (VPN-internal, web-api → dashboard)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class IngestEvent(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
request_id: str = Field(max_length=64)
|
||||||
|
# Source service. Senders that don't pass it default to 'web' for back-compat
|
||||||
|
# with the original DashboardEventSink in the web-api module.
|
||||||
|
module: str = Field(default="web", max_length=16)
|
||||||
|
tier: str = Field(default="free", max_length=16)
|
||||||
|
endpoint: str = Field(max_length=64)
|
||||||
|
provider: str | None = Field(default=None, max_length=32)
|
||||||
|
claim: str | None = None
|
||||||
|
query: str | None = None
|
||||||
|
duration_ms: int | None = None
|
||||||
|
status_code: int | None = None
|
||||||
|
results_count: int | None = None
|
||||||
|
evidence_count: int | None = None
|
||||||
|
error: str | None = None
|
||||||
|
cost_usd: float | None = None
|
||||||
|
user_id: str | None = Field(default=None, max_length=128)
|
||||||
|
stages: list[dict[str, Any]] | None = None
|
||||||
|
raw_request: dict[str, Any] | None = None
|
||||||
|
raw_response: dict[str, Any] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ingest/event")
|
||||||
|
async def ingest_event(
|
||||||
|
event: IngestEvent,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Receive a single request event from web-api middleware."""
|
||||||
|
# Trim bulky fields
|
||||||
|
claim = event.claim[:2000] if event.claim else None
|
||||||
|
query = event.query[:2000] if event.query else None
|
||||||
|
error = event.error[:2000] if event.error else None
|
||||||
|
|
||||||
|
# Compute cost if the sender didn't provide it
|
||||||
|
cost = event.cost_usd
|
||||||
|
if cost is None:
|
||||||
|
cost = estimate_cost(event.endpoint, event.tier, event.provider)
|
||||||
|
|
||||||
|
row = RequestHistory(
|
||||||
|
request_id=event.request_id,
|
||||||
|
module=event.module,
|
||||||
|
tier=event.tier,
|
||||||
|
endpoint=event.endpoint,
|
||||||
|
provider=event.provider,
|
||||||
|
claim=claim,
|
||||||
|
query=query,
|
||||||
|
duration_ms=event.duration_ms,
|
||||||
|
status_code=event.status_code,
|
||||||
|
results_count=event.results_count,
|
||||||
|
evidence_count=event.evidence_count,
|
||||||
|
error=error,
|
||||||
|
cost_usd=cost,
|
||||||
|
user_id=event.user_id,
|
||||||
|
stages=event.stages,
|
||||||
|
raw_request=event.raw_request,
|
||||||
|
raw_response=event.raw_response,
|
||||||
|
)
|
||||||
|
session.add(row)
|
||||||
|
try:
|
||||||
|
await session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
await session.rollback()
|
||||||
|
logger.warning("Ingest failed for %s: %s", event.request_id, e)
|
||||||
|
return {"stored": False, "error": str(e)}
|
||||||
|
return {"stored": True, "id": row.id}
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
"""Central AI monitoring panel (Val 1).
|
||||||
|
|
||||||
|
Aggregates, in one place, data that already exists across the platform but was
|
||||||
|
not surfaced in the UI:
|
||||||
|
- health of all AI services (concurrent probes)
|
||||||
|
- RabbitMQ queue depths (management API)
|
||||||
|
- latency percentiles p50/p90/p99 (Prometheus)
|
||||||
|
|
||||||
|
Every source is fail-open: a missing/unreachable source yields a disabled or
|
||||||
|
empty section, never a 500 — the panel must never take the dashboard down.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from dashboard.api.dependencies import verify_bearer_token
|
||||||
|
from dashboard.config import SettingsCache
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("monitoring")
|
||||||
|
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||||
|
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||||
|
|
||||||
|
|
||||||
|
def _service_endpoints() -> dict[str, str]:
|
||||||
|
"""All AI services to monitor → health/info URL (env-overridable).
|
||||||
|
|
||||||
|
Prefers ``/v1/info`` where the service exposes it (richer payload), else
|
||||||
|
``/health``. Override any entry with ``DASHBOARD_<ID>_HEALTH_URL``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
s = SettingsCache.get()
|
||||||
|
|
||||||
|
def envurl(key: str, default: str) -> str:
|
||||||
|
return os.environ.get(f"DASHBOARD_{key.upper()}_HEALTH_URL", default)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"web": envurl("web", f"{s.web_api_url}/v1/info"),
|
||||||
|
"llm": envurl("llm", f"{s.llm_api_url}/v1/info"),
|
||||||
|
"embeddings": envurl("embeddings", "http://10.11.10.17:14100/health"),
|
||||||
|
"rerank": envurl("rerank", "http://10.11.10.17:14200/health"),
|
||||||
|
"audio": envurl("audio", "http://10.11.10.17:54300/v1/info"),
|
||||||
|
"video": envurl("video", "http://10.11.10.17:54600/v1/info"),
|
||||||
|
"extractors": envurl("extractors", "http://10.11.10.17:54400/health"),
|
||||||
|
"forensic": envurl("forensic", "http://10.11.10.17:54700/health"),
|
||||||
|
"catalog": envurl("catalog", "http://10.11.10.17:11000/v1/info"),
|
||||||
|
"gateway": envurl("gateway", "http://10.11.10.17:11000/v1/info"),
|
||||||
|
"brain": envurl("brain", f"{s.brain_url}/v1/info"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _probe(client: httpx.AsyncClient, module_id: str, url: str) -> dict[str, Any]:
|
||||||
|
"""Probe one service health endpoint; normalized, never raises."""
|
||||||
|
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
r = await client.get(url)
|
||||||
|
latency_ms = round((time.monotonic() - started) * 1000, 1)
|
||||||
|
if r.status_code >= 500:
|
||||||
|
status = "down"
|
||||||
|
elif r.status_code >= 400:
|
||||||
|
status = "degraded"
|
||||||
|
else:
|
||||||
|
status = "healthy"
|
||||||
|
info: Any = None
|
||||||
|
if status == "healthy":
|
||||||
|
try:
|
||||||
|
info = r.json()
|
||||||
|
except Exception: # noqa: BLE001 - body is best-effort
|
||||||
|
info = None
|
||||||
|
return {
|
||||||
|
"module": module_id,
|
||||||
|
"url": url,
|
||||||
|
"status": status,
|
||||||
|
"http_status": r.status_code,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"info": info,
|
||||||
|
}
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return {
|
||||||
|
"module": module_id,
|
||||||
|
"url": url,
|
||||||
|
"status": "down",
|
||||||
|
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||||
|
"error": "timeout (>3s)",
|
||||||
|
}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {
|
||||||
|
"module": module_id,
|
||||||
|
"url": url,
|
||||||
|
"status": "down",
|
||||||
|
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||||
|
"error": str(e)[:200],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/services")
|
||||||
|
async def monitoring_services() -> dict[str, Any]:
|
||||||
|
"""Concurrent health of every AI service, plus a roll-up summary."""
|
||||||
|
|
||||||
|
endpoints = _service_endpoints()
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(_probe(client, mid, url) for mid, url in endpoints.items())
|
||||||
|
)
|
||||||
|
summary = {"healthy": 0, "degraded": 0, "down": 0, "total": len(results)}
|
||||||
|
for r in results:
|
||||||
|
summary[r["status"]] = summary.get(r["status"], 0) + 1
|
||||||
|
return {"services": list(results), "summary": summary}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/queues")
|
||||||
|
async def monitoring_queues() -> dict[str, Any]:
|
||||||
|
"""RabbitMQ queue depths via the management API (fail-open if unset)."""
|
||||||
|
|
||||||
|
s = SettingsCache.get()
|
||||||
|
if not s.rabbitmq_mgmt_url:
|
||||||
|
return {"enabled": False, "queues": [], "reason": "rabbitmq_mgmt_url not set"}
|
||||||
|
|
||||||
|
url = f"{s.rabbitmq_mgmt_url.rstrip('/')}/api/queues"
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=4.0) as client:
|
||||||
|
r = await client.get(
|
||||||
|
url, auth=(s.rabbitmq_mgmt_user, s.rabbitmq_mgmt_password)
|
||||||
|
)
|
||||||
|
if r.status_code != 200:
|
||||||
|
return {"enabled": True, "queues": [], "error": f"HTTP {r.status_code}"}
|
||||||
|
queues = [
|
||||||
|
{
|
||||||
|
"name": q.get("name"),
|
||||||
|
"vhost": q.get("vhost"),
|
||||||
|
"messages": q.get("messages", 0),
|
||||||
|
"ready": q.get("messages_ready", 0),
|
||||||
|
"unacked": q.get("messages_unacknowledged", 0),
|
||||||
|
"consumers": q.get("consumers", 0),
|
||||||
|
"state": q.get("state"),
|
||||||
|
}
|
||||||
|
for q in (r.json() if isinstance(r.json(), list) else [])
|
||||||
|
]
|
||||||
|
queues.sort(key=lambda q: q["messages"], reverse=True)
|
||||||
|
return {"enabled": True, "queues": queues}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("rabbitmq_probe_failed: %s", e)
|
||||||
|
return {"enabled": True, "queues": [], "error": str(e)[:200]}
|
||||||
|
|
||||||
|
|
||||||
|
async def _prom_quantile(
|
||||||
|
client: httpx.AsyncClient, base: str, q: float
|
||||||
|
) -> dict[str, float]:
|
||||||
|
"""Query one latency quantile per Prometheus job."""
|
||||||
|
|
||||||
|
expr = (
|
||||||
|
f"histogram_quantile({q}, sum(rate("
|
||||||
|
f"http_request_duration_seconds_bucket[5m])) by (le, job))"
|
||||||
|
)
|
||||||
|
r = await client.get(f"{base}/api/v1/query", params={"query": expr})
|
||||||
|
out: dict[str, float] = {}
|
||||||
|
if r.status_code != 200:
|
||||||
|
return out
|
||||||
|
for series in r.json().get("data", {}).get("result", []):
|
||||||
|
job = series.get("metric", {}).get("job")
|
||||||
|
val = series.get("value", [None, None])[1]
|
||||||
|
if job and val is not None:
|
||||||
|
try:
|
||||||
|
out[job] = round(float(val) * 1000, 1) # seconds → ms
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/monitoring/latency")
|
||||||
|
async def monitoring_latency() -> dict[str, Any]:
|
||||||
|
"""p50/p90/p99 request latency per service from Prometheus (fail-open)."""
|
||||||
|
|
||||||
|
s = SettingsCache.get()
|
||||||
|
if not s.prometheus_url:
|
||||||
|
return {"enabled": False, "services": [], "reason": "prometheus_url not set"}
|
||||||
|
|
||||||
|
base = s.prometheus_url.rstrip("/")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
p50, p90, p99 = await asyncio.gather(
|
||||||
|
_prom_quantile(client, base, 0.5),
|
||||||
|
_prom_quantile(client, base, 0.9),
|
||||||
|
_prom_quantile(client, base, 0.99),
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
logger.warning("prometheus_query_failed: %s", e)
|
||||||
|
return {"enabled": True, "services": [], "error": str(e)[:200]}
|
||||||
|
|
||||||
|
jobs = sorted(set(p50) | set(p90) | set(p99))
|
||||||
|
services = [
|
||||||
|
{
|
||||||
|
"job": job,
|
||||||
|
"p50_ms": p50.get(job),
|
||||||
|
"p90_ms": p90.get(job),
|
||||||
|
"p99_ms": p99.get(job),
|
||||||
|
}
|
||||||
|
for job in jobs
|
||||||
|
]
|
||||||
|
return {"enabled": True, "services": services}
|
||||||
498
ai_platform/modules/dashboard/src/dashboard/api/routes/pages.py
Normal file
498
ai_platform/modules/dashboard/src/dashboard/api/routes/pages.py
Normal file
|
|
@ -0,0 +1,498 @@
|
||||||
|
"""HTML pages served by Jinja2."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import HTMLResponse, Response
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy import delete, desc, func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from dashboard.api.dependencies import get_registry
|
||||||
|
from dashboard.api.routes.config import KNOWN_KEYS, _coerce_and_validate
|
||||||
|
from dashboard.db.models import (
|
||||||
|
ArticlesArchive,
|
||||||
|
AuditLog,
|
||||||
|
ClaimArticle,
|
||||||
|
ClaimsArchive,
|
||||||
|
ConfigOverride,
|
||||||
|
RequestHistory,
|
||||||
|
)
|
||||||
|
from dashboard.db.session import get_session
|
||||||
|
from dashboard.providers.registry import ProviderRegistry
|
||||||
|
|
||||||
|
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates"
|
||||||
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def overview(
|
||||||
|
request: Request,
|
||||||
|
registry: ProviderRegistry = Depends(get_registry),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
provider_stats = await registry.get_all()
|
||||||
|
summary = await _get_summary(session, hours=24)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="overview.html",
|
||||||
|
context={
|
||||||
|
"providers": [s.to_dict() for s in provider_stats],
|
||||||
|
"summary": summary,
|
||||||
|
"active_page": "overview",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history", response_class=HTMLResponse)
|
||||||
|
async def history_page(
|
||||||
|
request: Request,
|
||||||
|
tier: str | None = Query(default=None),
|
||||||
|
provider: str | None = Query(default=None),
|
||||||
|
endpoint: str | None = Query(default=None),
|
||||||
|
hours: int = Query(default=168, ge=1, le=720),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
q = select(RequestHistory).where(RequestHistory.created_at >= since)
|
||||||
|
if tier:
|
||||||
|
q = q.where(RequestHistory.tier == tier)
|
||||||
|
if provider:
|
||||||
|
q = q.where(RequestHistory.provider == provider)
|
||||||
|
if endpoint:
|
||||||
|
q = q.where(RequestHistory.endpoint == endpoint)
|
||||||
|
q = q.order_by(desc(RequestHistory.created_at)).limit(limit)
|
||||||
|
|
||||||
|
rows = (await session.execute(q)).scalars().all()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="history.html",
|
||||||
|
context={
|
||||||
|
"items": rows,
|
||||||
|
"filters": {
|
||||||
|
"tier": tier or "",
|
||||||
|
"provider": provider or "",
|
||||||
|
"endpoint": endpoint or "",
|
||||||
|
"hours": hours,
|
||||||
|
},
|
||||||
|
"active_page": "history",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/{request_id}", response_class=HTMLResponse)
|
||||||
|
async def history_detail(
|
||||||
|
request: Request,
|
||||||
|
request_id: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
q = select(RequestHistory).where(RequestHistory.request_id == request_id)
|
||||||
|
row = (await session.execute(q)).scalar_one_or_none()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Not found")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="history_detail.html",
|
||||||
|
context={
|
||||||
|
"item": row,
|
||||||
|
"active_page": "history",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/providers", response_class=HTMLResponse)
|
||||||
|
async def providers_page(
|
||||||
|
request: Request,
|
||||||
|
registry: ProviderRegistry = Depends(get_registry),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
stats = await registry.get_all(force=True)
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="providers.html",
|
||||||
|
context={
|
||||||
|
"providers": [s.to_dict() for s in stats],
|
||||||
|
"active_page": "providers",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cost", response_class=HTMLResponse)
|
||||||
|
async def cost_page(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
registry: ProviderRegistry = Depends(get_registry),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Cost dashboard — spend breakdowns + projections."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
since_24h = now - timedelta(hours=24)
|
||||||
|
since_7d = now - timedelta(days=7)
|
||||||
|
since_30d = now - timedelta(days=30)
|
||||||
|
|
||||||
|
async def _sum_cost(since: datetime) -> float:
|
||||||
|
q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||||
|
RequestHistory.created_at >= since
|
||||||
|
)
|
||||||
|
return float((await session.execute(q)).scalar() or 0)
|
||||||
|
|
||||||
|
cost_24h = await _sum_cost(since_24h)
|
||||||
|
cost_7d = await _sum_cost(since_7d)
|
||||||
|
cost_30d = await _sum_cost(since_30d)
|
||||||
|
|
||||||
|
# Projected monthly based on last 24h burn rate
|
||||||
|
projected_monthly = round(cost_24h * 30, 2)
|
||||||
|
|
||||||
|
# Cost by provider (30d)
|
||||||
|
provider_q = (
|
||||||
|
select(
|
||||||
|
RequestHistory.provider,
|
||||||
|
func.count(RequestHistory.id).label("count"),
|
||||||
|
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
RequestHistory.created_at >= since_30d,
|
||||||
|
RequestHistory.provider.is_not(None),
|
||||||
|
)
|
||||||
|
.group_by(RequestHistory.provider)
|
||||||
|
.order_by(desc("cost"))
|
||||||
|
)
|
||||||
|
by_provider = [
|
||||||
|
{"provider": row.provider, "count": row.count, "cost": float(row.cost or 0)}
|
||||||
|
for row in (await session.execute(provider_q)).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Cost by tier (30d)
|
||||||
|
tier_q = (
|
||||||
|
select(
|
||||||
|
RequestHistory.tier,
|
||||||
|
func.count(RequestHistory.id).label("count"),
|
||||||
|
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||||
|
)
|
||||||
|
.where(RequestHistory.created_at >= since_30d)
|
||||||
|
.group_by(RequestHistory.tier)
|
||||||
|
)
|
||||||
|
by_tier = [
|
||||||
|
{"tier": row.tier, "count": row.count, "cost": float(row.cost or 0)}
|
||||||
|
for row in (await session.execute(tier_q)).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Top 10 most expensive requests
|
||||||
|
top_q = (
|
||||||
|
select(RequestHistory)
|
||||||
|
.where(
|
||||||
|
RequestHistory.created_at >= since_30d,
|
||||||
|
RequestHistory.cost_usd.is_not(None),
|
||||||
|
)
|
||||||
|
.order_by(desc(RequestHistory.cost_usd))
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
top = (await session.execute(top_q)).scalars().all()
|
||||||
|
|
||||||
|
# Provider quota vs budget
|
||||||
|
provider_stats = await registry.get_all()
|
||||||
|
budget_bars = []
|
||||||
|
for p in provider_stats:
|
||||||
|
if p.quota_limit:
|
||||||
|
budget_bars.append(
|
||||||
|
{
|
||||||
|
"name": p.display_name,
|
||||||
|
"kind": p.kind,
|
||||||
|
"used": p.quota_used or 0,
|
||||||
|
"limit": p.quota_limit,
|
||||||
|
"percent": p.quota_percent_used or 0,
|
||||||
|
"unit": p.quota_unit,
|
||||||
|
"plan_price": p.plan_price_monthly,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="cost.html",
|
||||||
|
context={
|
||||||
|
"cost_24h": round(cost_24h, 4),
|
||||||
|
"cost_7d": round(cost_7d, 4),
|
||||||
|
"cost_30d": round(cost_30d, 4),
|
||||||
|
"projected_monthly": projected_monthly,
|
||||||
|
"by_provider": by_provider,
|
||||||
|
"by_tier": by_tier,
|
||||||
|
"top": top,
|
||||||
|
"budgets": budget_bars,
|
||||||
|
"active_page": "cost",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/archive", response_class=HTMLResponse)
|
||||||
|
async def archive_page(
|
||||||
|
request: Request,
|
||||||
|
q: str | None = Query(default=None),
|
||||||
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Browse archived claims."""
|
||||||
|
stmt = select(ClaimsArchive)
|
||||||
|
if q:
|
||||||
|
stmt = stmt.where(ClaimsArchive.claim.ilike(f"%{q}%"))
|
||||||
|
stmt = stmt.order_by(desc(ClaimsArchive.created_at)).limit(limit)
|
||||||
|
|
||||||
|
claims = (await session.execute(stmt)).scalars().all()
|
||||||
|
total = (
|
||||||
|
await session.execute(select(func.count(ClaimsArchive.id)))
|
||||||
|
).scalar() or 0
|
||||||
|
articles_total = (
|
||||||
|
await session.execute(select(func.count(ArticlesArchive.id)))
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="archive.html",
|
||||||
|
context={
|
||||||
|
"claims": claims,
|
||||||
|
"query": q or "",
|
||||||
|
"total_claims": total,
|
||||||
|
"total_articles": articles_total,
|
||||||
|
"active_page": "archive",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/archive/{claim_id}", response_class=HTMLResponse)
|
||||||
|
async def archive_detail(
|
||||||
|
request: Request,
|
||||||
|
claim_id: int,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Detail of a single archived claim with its articles."""
|
||||||
|
claim = (
|
||||||
|
await session.execute(select(ClaimsArchive).where(ClaimsArchive.id == claim_id))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if claim is None:
|
||||||
|
raise HTTPException(status_code=404)
|
||||||
|
|
||||||
|
links_stmt = (
|
||||||
|
select(ClaimArticle, ArticlesArchive)
|
||||||
|
.join(ArticlesArchive, ClaimArticle.article_id == ArticlesArchive.id)
|
||||||
|
.where(ClaimArticle.claim_id == claim_id)
|
||||||
|
)
|
||||||
|
links = (await session.execute(links_stmt)).all()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="archive_detail.html",
|
||||||
|
context={
|
||||||
|
"claim": claim,
|
||||||
|
"articles": [
|
||||||
|
{"article": article, "link": link} for link, article in links
|
||||||
|
],
|
||||||
|
"active_page": "archive",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/audit", response_class=HTMLResponse)
|
||||||
|
async def audit_page(
|
||||||
|
request: Request,
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Audit log page — shows who changed what, when."""
|
||||||
|
q = select(AuditLog).order_by(desc(AuditLog.timestamp)).limit(limit)
|
||||||
|
rows = (await session.execute(q)).scalars().all()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="audit.html",
|
||||||
|
context={"entries": rows, "active_page": "audit"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config", response_class=HTMLResponse)
|
||||||
|
async def config_page(
|
||||||
|
request: Request,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> HTMLResponse:
|
||||||
|
"""Runtime configuration page — toggle providers, strategy, models."""
|
||||||
|
overrides_rows = (await session.execute(select(ConfigOverride))).scalars().all()
|
||||||
|
overrides = {r.key: r for r in overrides_rows}
|
||||||
|
|
||||||
|
items = []
|
||||||
|
categories: dict[str, list[dict]] = {
|
||||||
|
"providers": [],
|
||||||
|
"routing": [],
|
||||||
|
"llm": [],
|
||||||
|
"tiers": [],
|
||||||
|
}
|
||||||
|
for key, meta in KNOWN_KEYS.items():
|
||||||
|
row = overrides.get(key)
|
||||||
|
entry = {
|
||||||
|
"key": key,
|
||||||
|
"meta": meta,
|
||||||
|
"value": row.value if row else meta["default"],
|
||||||
|
"is_override": row is not None,
|
||||||
|
"updated_at": row.updated_at if row else None,
|
||||||
|
"updated_by": row.updated_by if row else None,
|
||||||
|
}
|
||||||
|
items.append(entry)
|
||||||
|
categories.setdefault(meta.get("category", "other"), []).append(entry)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="config.html",
|
||||||
|
context={
|
||||||
|
"categories": categories,
|
||||||
|
"active_page": "config",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/{key}", response_class=HTMLResponse)
|
||||||
|
async def config_update(
|
||||||
|
request: Request,
|
||||||
|
key: str,
|
||||||
|
value: str = Form(...),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
"""HTMX form handler — update a config key and return the row fragment."""
|
||||||
|
if key not in KNOWN_KEYS:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown key")
|
||||||
|
|
||||||
|
meta = KNOWN_KEYS[key]
|
||||||
|
coerced = _coerce_and_validate(key, value, meta)
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(ConfigOverride).where(ConfigOverride.key == key))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
old_value = existing.value if existing else meta["default"]
|
||||||
|
username = request.headers.get("X-User", "dashboard-ui")
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
session.add(
|
||||||
|
ConfigOverride(
|
||||||
|
key=key,
|
||||||
|
value=coerced,
|
||||||
|
updated_by=username,
|
||||||
|
description=meta.get("description"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
existing.value = coerced
|
||||||
|
existing.updated_by = username
|
||||||
|
existing.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
session.add(
|
||||||
|
AuditLog(
|
||||||
|
username=username,
|
||||||
|
action="config.set",
|
||||||
|
target=key,
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=coerced,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"key": key,
|
||||||
|
"meta": meta,
|
||||||
|
"value": coerced,
|
||||||
|
"is_override": True,
|
||||||
|
"updated_at": datetime.now(timezone.utc),
|
||||||
|
"updated_by": username,
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/config_row.html",
|
||||||
|
context={"entry": entry},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/{key}/reset", response_class=HTMLResponse)
|
||||||
|
async def config_reset(
|
||||||
|
request: Request,
|
||||||
|
key: str,
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> Response:
|
||||||
|
"""HTMX — revert a config key to its default."""
|
||||||
|
if key not in KNOWN_KEYS:
|
||||||
|
raise HTTPException(status_code=404, detail="Unknown key")
|
||||||
|
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(ConfigOverride).where(ConfigOverride.key == key))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
old_value = existing.value if existing else None
|
||||||
|
|
||||||
|
if existing is not None:
|
||||||
|
await session.execute(
|
||||||
|
delete(ConfigOverride).where(ConfigOverride.key == key)
|
||||||
|
)
|
||||||
|
session.add(
|
||||||
|
AuditLog(
|
||||||
|
username=request.headers.get("X-User", "dashboard-ui"),
|
||||||
|
action="config.reset",
|
||||||
|
target=key,
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
meta = KNOWN_KEYS[key]
|
||||||
|
entry = {
|
||||||
|
"key": key,
|
||||||
|
"meta": meta,
|
||||||
|
"value": meta["default"],
|
||||||
|
"is_override": False,
|
||||||
|
"updated_at": None,
|
||||||
|
"updated_by": None,
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
request=request,
|
||||||
|
name="partials/config_row.html",
|
||||||
|
context={"entry": entry},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_summary(session: AsyncSession, hours: int = 24) -> dict:
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
|
||||||
|
total_q = select(func.count(RequestHistory.id)).where(
|
||||||
|
RequestHistory.created_at >= since
|
||||||
|
)
|
||||||
|
total = (await session.execute(total_q)).scalar() or 0
|
||||||
|
|
||||||
|
tier_q = (
|
||||||
|
select(RequestHistory.tier, func.count(RequestHistory.id))
|
||||||
|
.where(RequestHistory.created_at >= since)
|
||||||
|
.group_by(RequestHistory.tier)
|
||||||
|
)
|
||||||
|
tiers = dict((await session.execute(tier_q)).all())
|
||||||
|
|
||||||
|
err_q = select(func.count(RequestHistory.id)).where(
|
||||||
|
RequestHistory.created_at >= since,
|
||||||
|
RequestHistory.status_code >= 400,
|
||||||
|
)
|
||||||
|
errors = (await session.execute(err_q)).scalar() or 0
|
||||||
|
|
||||||
|
avg_q = select(func.avg(RequestHistory.duration_ms)).where(
|
||||||
|
RequestHistory.created_at >= since
|
||||||
|
)
|
||||||
|
avg_duration = float((await session.execute(avg_q)).scalar() or 0)
|
||||||
|
|
||||||
|
cost_q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||||
|
RequestHistory.created_at >= since
|
||||||
|
)
|
||||||
|
total_cost = float((await session.execute(cost_q)).scalar() or 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"errors": errors,
|
||||||
|
"error_rate": round((errors / total * 100), 1) if total else 0.0,
|
||||||
|
"avg_duration_ms": round(avg_duration, 0),
|
||||||
|
"total_cost": round(total_cost, 4),
|
||||||
|
"by_tier": {k or "unknown": v for k, v in tiers.items()},
|
||||||
|
"hours": hours,
|
||||||
|
}
|
||||||
100
ai_platform/modules/dashboard/src/dashboard/api/routes/proxy.py
Normal file
100
ai_platform/modules/dashboard/src/dashboard/api/routes/proxy.py
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
"""Module health proxy — pings each module's health endpoint, returns normalized status."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
|
||||||
|
from dashboard.api.dependencies import verify_bearer_token
|
||||||
|
from dashboard.config import SettingsCache
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("proxy")
|
||||||
|
# RBAC: protected when auth is configured; open in dev when not.
|
||||||
|
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||||
|
|
||||||
|
|
||||||
|
# Module → health endpoint URL.
|
||||||
|
# Reads optional env overrides first; falls back to settings or known defaults.
|
||||||
|
# Set DASHBOARD_<module>_HEALTH_URL to override (e.g. DASHBOARD_EMBEDDINGS_HEALTH_URL=http://10.11.10.17:14100).
|
||||||
|
def _module_endpoints() -> dict[str, str]:
|
||||||
|
s = SettingsCache.get()
|
||||||
|
|
||||||
|
def envurl(key: str, default: str) -> str:
|
||||||
|
return os.environ.get(f"DASHBOARD_{key.upper()}_HEALTH_URL", default)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"web": envurl("web", f"{s.web_api_url}/v1/info"),
|
||||||
|
"llm": envurl("llm", f"{s.llm_api_url}/v1/info"),
|
||||||
|
"embeddings": envurl("embeddings", "http://10.11.10.17:14100/v1/info"),
|
||||||
|
"rerank": envurl("rerank", "http://10.11.10.17:14200/v1/info"),
|
||||||
|
"audio": envurl("audio", "http://10.11.10.17:54300/v1/info"),
|
||||||
|
"video": envurl("video", "http://10.11.10.17:54600/v1/info"),
|
||||||
|
"catalog": envurl("catalog", "http://10.11.10.17:11000/v1/info"),
|
||||||
|
"gateway": envurl("gateway", "http://10.11.10.17:11000/v1/info"),
|
||||||
|
"brain": envurl("brain", f"{s.brain_url}/v1/info"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/proxy/{module_id}/health")
|
||||||
|
async def module_health(module_id: str) -> dict[str, Any]:
|
||||||
|
"""Ping a module's health endpoint. Normalized response: status, latency, error."""
|
||||||
|
endpoints = _module_endpoints()
|
||||||
|
if module_id not in endpoints:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown module: {module_id}")
|
||||||
|
|
||||||
|
url = endpoints[module_id]
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||||
|
r = await client.get(url)
|
||||||
|
latency_ms = round((time.monotonic() - started) * 1000, 1)
|
||||||
|
if r.status_code >= 500:
|
||||||
|
return {
|
||||||
|
"module": module_id,
|
||||||
|
"url": url,
|
||||||
|
"status": "down",
|
||||||
|
"http_status": r.status_code,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"error": r.text[:200] if r.text else None,
|
||||||
|
}
|
||||||
|
if r.status_code >= 400:
|
||||||
|
return {
|
||||||
|
"module": module_id,
|
||||||
|
"url": url,
|
||||||
|
"status": "degraded",
|
||||||
|
"http_status": r.status_code,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"error": r.text[:200] if r.text else None,
|
||||||
|
}
|
||||||
|
body: Any = None
|
||||||
|
try:
|
||||||
|
body = r.json()
|
||||||
|
except Exception: # noqa: BLE001 — broad on purpose, body is best-effort
|
||||||
|
body = None
|
||||||
|
return {
|
||||||
|
"module": module_id,
|
||||||
|
"url": url,
|
||||||
|
"status": "healthy",
|
||||||
|
"http_status": r.status_code,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"info": body,
|
||||||
|
}
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
return {
|
||||||
|
"module": module_id,
|
||||||
|
"url": url,
|
||||||
|
"status": "down",
|
||||||
|
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||||
|
"error": "timeout (>3s)",
|
||||||
|
}
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
return {
|
||||||
|
"module": module_id,
|
||||||
|
"url": url,
|
||||||
|
"status": "down",
|
||||||
|
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||||
|
"error": str(e)[:200],
|
||||||
|
}
|
||||||
300
ai_platform/modules/dashboard/src/dashboard/api/routes/stats.py
Normal file
300
ai_platform/modules/dashboard/src/dashboard/api/routes/stats.py
Normal file
|
|
@ -0,0 +1,300 @@
|
||||||
|
"""Live stats endpoints."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from dashboard.api.dependencies import get_registry, verify_bearer_token
|
||||||
|
from dashboard.db.models import RequestHistory
|
||||||
|
from dashboard.db.session import get_session
|
||||||
|
from dashboard.providers.registry import ProviderRegistry
|
||||||
|
|
||||||
|
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||||
|
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/providers")
|
||||||
|
async def providers_stats(
|
||||||
|
force: bool = Query(default=False),
|
||||||
|
registry: ProviderRegistry = Depends(get_registry),
|
||||||
|
) -> dict:
|
||||||
|
"""Return live stats for all providers (cached)."""
|
||||||
|
stats = await registry.get_all(force=force)
|
||||||
|
return {
|
||||||
|
"providers": [s.to_dict() for s in stats],
|
||||||
|
"last_refresh": registry.last_refresh_iso,
|
||||||
|
"age_seconds": registry.age_seconds,
|
||||||
|
"cache_ttl_seconds": registry.settings.provider_stats_cache_seconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/summary")
|
||||||
|
async def summary_stats(
|
||||||
|
hours: int = Query(default=24, ge=1, le=720),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Aggregated counters over the last N hours."""
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# Total requests
|
||||||
|
total_q = select(func.count(RequestHistory.id)).where(
|
||||||
|
RequestHistory.created_at >= since
|
||||||
|
)
|
||||||
|
total = (await session.execute(total_q)).scalar() or 0
|
||||||
|
|
||||||
|
# By tier
|
||||||
|
tier_q = (
|
||||||
|
select(RequestHistory.tier, func.count(RequestHistory.id))
|
||||||
|
.where(RequestHistory.created_at >= since)
|
||||||
|
.group_by(RequestHistory.tier)
|
||||||
|
)
|
||||||
|
tiers = dict((await session.execute(tier_q)).all())
|
||||||
|
|
||||||
|
# By provider
|
||||||
|
provider_q = (
|
||||||
|
select(RequestHistory.provider, func.count(RequestHistory.id))
|
||||||
|
.where(
|
||||||
|
RequestHistory.created_at >= since,
|
||||||
|
RequestHistory.provider.is_not(None),
|
||||||
|
)
|
||||||
|
.group_by(RequestHistory.provider)
|
||||||
|
)
|
||||||
|
providers = dict((await session.execute(provider_q)).all())
|
||||||
|
|
||||||
|
# By endpoint
|
||||||
|
endpoint_q = (
|
||||||
|
select(RequestHistory.endpoint, func.count(RequestHistory.id))
|
||||||
|
.where(RequestHistory.created_at >= since)
|
||||||
|
.group_by(RequestHistory.endpoint)
|
||||||
|
)
|
||||||
|
endpoints = dict((await session.execute(endpoint_q)).all())
|
||||||
|
|
||||||
|
# Avg duration
|
||||||
|
avg_q = select(func.avg(RequestHistory.duration_ms)).where(
|
||||||
|
RequestHistory.created_at >= since
|
||||||
|
)
|
||||||
|
avg_duration = (await session.execute(avg_q)).scalar()
|
||||||
|
|
||||||
|
# Error rate
|
||||||
|
err_q = select(func.count(RequestHistory.id)).where(
|
||||||
|
RequestHistory.created_at >= since,
|
||||||
|
RequestHistory.status_code >= 400,
|
||||||
|
)
|
||||||
|
errors = (await session.execute(err_q)).scalar() or 0
|
||||||
|
|
||||||
|
# Total cost
|
||||||
|
cost_q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||||
|
RequestHistory.created_at >= since
|
||||||
|
)
|
||||||
|
total_cost = float((await session.execute(cost_q)).scalar() or 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"window_hours": hours,
|
||||||
|
"total_requests": total,
|
||||||
|
"total_errors": errors,
|
||||||
|
"error_rate": round(errors / total, 4) if total else 0,
|
||||||
|
"avg_duration_ms": round(float(avg_duration or 0), 2),
|
||||||
|
"total_cost_usd": round(total_cost, 4),
|
||||||
|
"by_tier": tiers,
|
||||||
|
"by_provider": providers,
|
||||||
|
"by_endpoint": endpoints,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/cost")
|
||||||
|
async def cost_stats(
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
registry: ProviderRegistry = Depends(get_registry),
|
||||||
|
) -> dict:
|
||||||
|
"""Cost rollups (24h / 7d / 30d) + breakdowns + budget bars."""
|
||||||
|
from sqlalchemy import desc
|
||||||
|
|
||||||
|
from dashboard.db.models import RequestHistory
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
since_24h = now - timedelta(hours=24)
|
||||||
|
since_7d = now - timedelta(days=7)
|
||||||
|
since_30d = now - timedelta(days=30)
|
||||||
|
|
||||||
|
async def _sum(since: datetime) -> float:
|
||||||
|
q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||||
|
RequestHistory.created_at >= since
|
||||||
|
)
|
||||||
|
return float((await session.execute(q)).scalar() or 0)
|
||||||
|
|
||||||
|
cost_24h = await _sum(since_24h)
|
||||||
|
cost_7d = await _sum(since_7d)
|
||||||
|
cost_30d = await _sum(since_30d)
|
||||||
|
|
||||||
|
# By provider (30d) — only paid providers (cost > 0). Brain/agent_v3 emit
|
||||||
|
# rows with cost=0 (no upstream provider), they would otherwise clutter the
|
||||||
|
# chart with empty bars labelled "v3", "get", etc.
|
||||||
|
prov_q = (
|
||||||
|
select(
|
||||||
|
RequestHistory.provider,
|
||||||
|
func.count(RequestHistory.id).label("count"),
|
||||||
|
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
RequestHistory.created_at >= since_30d,
|
||||||
|
RequestHistory.provider.is_not(None),
|
||||||
|
)
|
||||||
|
.group_by(RequestHistory.provider)
|
||||||
|
.having(func.coalesce(func.sum(RequestHistory.cost_usd), 0) > 0)
|
||||||
|
.order_by(desc("cost"))
|
||||||
|
)
|
||||||
|
by_provider = [
|
||||||
|
{"provider": r.provider, "count": r.count, "cost": float(r.cost or 0)}
|
||||||
|
for r in (await session.execute(prov_q)).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# By tier (30d)
|
||||||
|
tier_q = (
|
||||||
|
select(
|
||||||
|
RequestHistory.tier,
|
||||||
|
func.count(RequestHistory.id).label("count"),
|
||||||
|
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||||
|
)
|
||||||
|
.where(RequestHistory.created_at >= since_30d)
|
||||||
|
.group_by(RequestHistory.tier)
|
||||||
|
)
|
||||||
|
by_tier = [
|
||||||
|
{"tier": r.tier, "count": r.count, "cost": float(r.cost or 0)}
|
||||||
|
for r in (await session.execute(tier_q)).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Top expensive (30d) — exclude $0 rows. "Most expensive" of free/zero-cost
|
||||||
|
# rows is meaningless and crowds the table with brain/agent_v3 entries.
|
||||||
|
top_q = (
|
||||||
|
select(
|
||||||
|
RequestHistory.id,
|
||||||
|
RequestHistory.created_at,
|
||||||
|
RequestHistory.tier,
|
||||||
|
RequestHistory.provider,
|
||||||
|
RequestHistory.endpoint,
|
||||||
|
RequestHistory.cost_usd,
|
||||||
|
RequestHistory.duration_ms,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
RequestHistory.created_at >= since_30d,
|
||||||
|
RequestHistory.cost_usd.is_not(None),
|
||||||
|
RequestHistory.cost_usd > 0,
|
||||||
|
)
|
||||||
|
.order_by(desc(RequestHistory.cost_usd))
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
top = [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||||
|
"tier": r.tier,
|
||||||
|
"provider": r.provider,
|
||||||
|
"endpoint": r.endpoint,
|
||||||
|
"cost": float(r.cost_usd or 0),
|
||||||
|
"duration_ms": r.duration_ms,
|
||||||
|
}
|
||||||
|
for r in (await session.execute(top_q)).all()
|
||||||
|
]
|
||||||
|
|
||||||
|
# Budget bars from live provider stats
|
||||||
|
provider_stats = await registry.get_all()
|
||||||
|
budgets = []
|
||||||
|
for p in provider_stats:
|
||||||
|
if p.quota_limit:
|
||||||
|
budgets.append(
|
||||||
|
{
|
||||||
|
"name": p.display_name,
|
||||||
|
"kind": p.kind,
|
||||||
|
"used": p.quota_used or 0,
|
||||||
|
"limit": p.quota_limit,
|
||||||
|
"percent": p.quota_percent_used or 0,
|
||||||
|
"unit": p.quota_unit,
|
||||||
|
"plan_price": p.plan_price_monthly,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Smart projection: weighted blend of 7d and 30d daily averages.
|
||||||
|
# If we have 30d of data, prefer that average × 30. Else 7d × 30/7. Fallback to 24h × 30.
|
||||||
|
daily_30d = cost_30d / 30 if cost_30d > 0 else 0
|
||||||
|
daily_7d = cost_7d / 7 if cost_7d > 0 else 0
|
||||||
|
daily_24h = cost_24h
|
||||||
|
if daily_30d > 0 and daily_7d > 0:
|
||||||
|
# Weighted: 30d gives stability, 7d catches recent shifts
|
||||||
|
daily_blend = (daily_30d * 0.4) + (daily_7d * 0.6)
|
||||||
|
confidence = "stable"
|
||||||
|
basis = "blend(30d=40%, 7d=60%)"
|
||||||
|
elif daily_7d > 0:
|
||||||
|
daily_blend = daily_7d
|
||||||
|
confidence = "moderate"
|
||||||
|
basis = "7d_avg"
|
||||||
|
else:
|
||||||
|
daily_blend = daily_24h
|
||||||
|
confidence = "rough"
|
||||||
|
basis = "24h_only"
|
||||||
|
projected_monthly = round(daily_blend * 30, 2)
|
||||||
|
|
||||||
|
# Trend: last 7d vs previous 7d → "increasing", "decreasing", "stable"
|
||||||
|
cost_7d_prev = await _sum(now - timedelta(days=14)) - cost_7d
|
||||||
|
if cost_7d > 0 and cost_7d_prev > 0:
|
||||||
|
trend_pct = ((cost_7d - cost_7d_prev) / cost_7d_prev) * 100
|
||||||
|
if trend_pct > 10:
|
||||||
|
trend = "increasing"
|
||||||
|
elif trend_pct < -10:
|
||||||
|
trend = "decreasing"
|
||||||
|
else:
|
||||||
|
trend = "stable"
|
||||||
|
trend_pct = round(trend_pct, 1)
|
||||||
|
else:
|
||||||
|
trend = "unknown"
|
||||||
|
trend_pct = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"cost_24h": round(cost_24h, 4),
|
||||||
|
"cost_7d": round(cost_7d, 4),
|
||||||
|
"cost_30d": round(cost_30d, 4),
|
||||||
|
"projected_monthly": projected_monthly,
|
||||||
|
"projection_basis": basis,
|
||||||
|
"projection_confidence": confidence,
|
||||||
|
"trend": trend,
|
||||||
|
"trend_pct": trend_pct,
|
||||||
|
"by_provider": by_provider,
|
||||||
|
"by_tier": by_tier,
|
||||||
|
"top": top,
|
||||||
|
"budgets": budgets,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats/timeline")
|
||||||
|
async def timeline_stats(
|
||||||
|
hours: int = Query(default=24, ge=1, le=720),
|
||||||
|
session: AsyncSession = Depends(get_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Hourly request counts for charts."""
|
||||||
|
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
|
||||||
|
q = (
|
||||||
|
select(
|
||||||
|
func.date_trunc("hour", RequestHistory.created_at).label("hour"),
|
||||||
|
RequestHistory.tier,
|
||||||
|
func.count(RequestHistory.id).label("count"),
|
||||||
|
func.avg(RequestHistory.duration_ms).label("avg_ms"),
|
||||||
|
)
|
||||||
|
.where(RequestHistory.created_at >= since)
|
||||||
|
.group_by("hour", RequestHistory.tier)
|
||||||
|
.order_by("hour")
|
||||||
|
)
|
||||||
|
rows = (await session.execute(q)).all()
|
||||||
|
|
||||||
|
buckets: list[dict] = []
|
||||||
|
for row in rows:
|
||||||
|
buckets.append(
|
||||||
|
{
|
||||||
|
"hour": row.hour.isoformat() if row.hour else None,
|
||||||
|
"tier": row.tier,
|
||||||
|
"count": row.count,
|
||||||
|
"avg_ms": round(float(row.avg_ms or 0), 2),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"timeline": buckets, "window_hours": hours}
|
||||||
82
ai_platform/modules/dashboard/src/dashboard/auth.py
Normal file
82
ai_platform/modules/dashboard/src/dashboard/auth.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""User authentication — Bearer tokens mapped to named users in the DB.
|
||||||
|
|
||||||
|
Dashboard runs in an internal VPN so this is intentionally simple:
|
||||||
|
a shared table of (username, token_hash) rows with a role column for
|
||||||
|
future ACL expansion. Tokens are compared via constant-time hashing.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from dashboard.db.models import User
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("auth")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token(token: str) -> str:
|
||||||
|
"""Hash a raw token with SHA-256 for storage."""
|
||||||
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_token() -> str:
|
||||||
|
"""Generate a fresh URL-safe token (32 bytes of entropy)."""
|
||||||
|
return secrets.token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
|
async def find_user_by_token(session: AsyncSession, token: str) -> User | None:
|
||||||
|
"""Look up a user by raw token (hashes + constant-time compare).
|
||||||
|
|
||||||
|
Returns None when the token is unknown or the user is disabled.
|
||||||
|
"""
|
||||||
|
token_hash = hash_token(token)
|
||||||
|
rows = (await session.execute(select(User))).scalars().all()
|
||||||
|
for user in rows:
|
||||||
|
if hmac.compare_digest(user.token_hash, token_hash):
|
||||||
|
return user
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def upsert_user(
|
||||||
|
session: AsyncSession,
|
||||||
|
username: str,
|
||||||
|
token: str,
|
||||||
|
email: str | None = None,
|
||||||
|
role: str = "admin",
|
||||||
|
) -> User:
|
||||||
|
"""Create or update a user with the given token."""
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(User).where(User.username == username))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
token_hash = hash_token(token)
|
||||||
|
|
||||||
|
if existing is None:
|
||||||
|
user = User(
|
||||||
|
username=username,
|
||||||
|
email=email,
|
||||||
|
token_hash=token_hash,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
logger.info("Created user %s (role=%s)", username, role)
|
||||||
|
else:
|
||||||
|
existing.token_hash = token_hash
|
||||||
|
if email is not None:
|
||||||
|
existing.email = email
|
||||||
|
existing.role = role
|
||||||
|
user = existing
|
||||||
|
logger.info("Updated user %s (role=%s)", username, role)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def record_login(session: AsyncSession, user: User) -> None:
|
||||||
|
user.last_login = datetime.now(timezone.utc)
|
||||||
|
await session.commit()
|
||||||
103
ai_platform/modules/dashboard/src/dashboard/cli.py
Normal file
103
ai_platform/modules/dashboard/src/dashboard/cli.py
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
"""CLI utilities for admin tasks (create user, list users, etc.).
|
||||||
|
|
||||||
|
Usage (inside the container):
|
||||||
|
python -m dashboard.cli create-user didi
|
||||||
|
python -m dashboard.cli list-users
|
||||||
|
python -m dashboard.cli delete-user didi
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
|
from dashboard.auth import generate_token, upsert_user
|
||||||
|
from dashboard.config import SettingsCache
|
||||||
|
from dashboard.db.models import User
|
||||||
|
from dashboard.db.session import close_engine, get_session_factory, init_engine
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_user(username: str, email: str | None, role: str) -> None:
|
||||||
|
settings = SettingsCache.get()
|
||||||
|
init_engine(settings)
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as session:
|
||||||
|
token = generate_token()
|
||||||
|
user = await upsert_user(session, username, token, email=email, role=role)
|
||||||
|
print("User created:")
|
||||||
|
print(f" id: {user.id}")
|
||||||
|
print(f" username: {user.username}")
|
||||||
|
print(f" role: {user.role}")
|
||||||
|
print(f" email: {user.email or '—'}")
|
||||||
|
print()
|
||||||
|
print("Bearer token (SAVE THIS — it is not shown again):")
|
||||||
|
print(f" {token}")
|
||||||
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
async def _list_users() -> None:
|
||||||
|
settings = SettingsCache.get()
|
||||||
|
init_engine(settings)
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as session:
|
||||||
|
rows = (await session.execute(select(User).order_by(User.id))).scalars().all()
|
||||||
|
if not rows:
|
||||||
|
print("No users yet.")
|
||||||
|
await close_engine()
|
||||||
|
return
|
||||||
|
print(f"{'ID':<4} {'USERNAME':<20} {'ROLE':<10} {'EMAIL':<30} LAST LOGIN")
|
||||||
|
print("-" * 100)
|
||||||
|
for u in rows:
|
||||||
|
last = u.last_login.strftime("%Y-%m-%d %H:%M") if u.last_login else "never"
|
||||||
|
print(
|
||||||
|
f"{u.id:<4} {u.username:<20} {u.role:<10} {(u.email or '—'):<30} {last}"
|
||||||
|
)
|
||||||
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_user(username: str) -> None:
|
||||||
|
settings = SettingsCache.get()
|
||||||
|
init_engine(settings)
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
delete(User).where(User.username == username)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
if result.rowcount:
|
||||||
|
print(f"Deleted user {username}")
|
||||||
|
else:
|
||||||
|
print(f"User {username} not found")
|
||||||
|
await close_engine()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(prog="dashboard.cli", description="Dashboard admin")
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
p_create = sub.add_parser("create-user", help="Create or update a user")
|
||||||
|
p_create.add_argument("username")
|
||||||
|
p_create.add_argument("--email", default=None)
|
||||||
|
p_create.add_argument("--role", default="admin", choices=["admin", "viewer"])
|
||||||
|
|
||||||
|
sub.add_parser("list-users", help="List all users")
|
||||||
|
|
||||||
|
p_delete = sub.add_parser("delete-user", help="Delete a user by username")
|
||||||
|
p_delete.add_argument("username")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.command == "create-user":
|
||||||
|
asyncio.run(_create_user(args.username, args.email, args.role))
|
||||||
|
elif args.command == "list-users":
|
||||||
|
asyncio.run(_list_users())
|
||||||
|
elif args.command == "delete-user":
|
||||||
|
asyncio.run(_delete_user(args.username))
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
225
ai_platform/modules/dashboard/src/dashboard/config.py
Normal file
225
ai_platform/modules/dashboard/src/dashboard/config.py
Normal file
|
|
@ -0,0 +1,225 @@
|
||||||
|
"""Dashboard module configuration."""
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
|
from pydantic import Field, field_validator
|
||||||
|
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardSettings(BaseSettings):
|
||||||
|
"""Dashboard settings loaded from environment.
|
||||||
|
|
||||||
|
All variables use the DASHBOARD_ prefix.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_prefix="DASHBOARD_",
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="forbid",
|
||||||
|
env_ignore_empty=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# REQUIRED - Database
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
database_url: str = Field(
|
||||||
|
description="PostgreSQL async URL (e.g., postgresql+asyncpg://user:pass@host:5432/db)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# REQUIRED - Server
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
host: str = Field(default="0.0.0.0")
|
||||||
|
port: int = Field(default=51300)
|
||||||
|
external_url: str = Field(
|
||||||
|
description="External URL for OpenAPI (e.g., http://localhost:51300)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# OPTIONAL - Provider API keys (for live quota fetching)
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
serpapi_api_key: str | None = Field(default=None)
|
||||||
|
tavily_api_key: str | None = Field(default=None)
|
||||||
|
exa_api_key: str | None = Field(default=None)
|
||||||
|
linkup_api_key: str | None = Field(default=None)
|
||||||
|
brave_api_key: str | None = Field(default=None)
|
||||||
|
openrouter_api_key: str | None = Field(default=None)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# OPTIONAL - Upstream service URLs for health checks
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
web_api_url: str = Field(
|
||||||
|
default="http://didiAI-web-api:51100",
|
||||||
|
description="Web API base URL for health checks",
|
||||||
|
)
|
||||||
|
searxng_url: str = Field(
|
||||||
|
default="http://didiAI-web-searxng:8080",
|
||||||
|
description="SearXNG base URL for health check",
|
||||||
|
)
|
||||||
|
llm_api_url: str = Field(
|
||||||
|
default="http://didiAI-llm-api:14011",
|
||||||
|
description="LLM Inference API base URL",
|
||||||
|
)
|
||||||
|
vllm_qwen_url: str = Field(
|
||||||
|
default="http://didiAI-vllm-qwen3.5:14001",
|
||||||
|
description="Local vLLM Qwen base URL",
|
||||||
|
)
|
||||||
|
llamacpp_urls: Annotated[list[str], NoDecode] = Field(
|
||||||
|
default_factory=lambda: [
|
||||||
|
"http://10.11.10.18:14001",
|
||||||
|
"http://10.11.10.19:14001",
|
||||||
|
],
|
||||||
|
description="Comma-separated list of llama.cpp server URLs",
|
||||||
|
)
|
||||||
|
brain_url: str = Field(
|
||||||
|
default="http://didibrain-api:8090",
|
||||||
|
description="didi-brain base URL — proxied via /api/brain/* for the SPA",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Observability sources (AI monitoring panel — Val 1)
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
prometheus_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Prometheus base URL (e.g. http://prometheus:9090). Empty disables the "
|
||||||
|
"latency-percentiles panel; health/queues still work."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rabbitmq_mgmt_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"RabbitMQ management API base URL (e.g. "
|
||||||
|
"http://staging-dataLayer-rabbitmq:15672). Empty disables the queue panel."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
rabbitmq_mgmt_user: str = Field(
|
||||||
|
default="guest", description="RabbitMQ management API user"
|
||||||
|
)
|
||||||
|
rabbitmq_mgmt_password: str = Field(
|
||||||
|
default="guest", description="RabbitMQ management API password"
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("llamacpp_urls", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_llamacpp_urls(cls, v: str | list[str] | None) -> list[str]:
|
||||||
|
if v is None or v == "":
|
||||||
|
return []
|
||||||
|
if isinstance(v, str):
|
||||||
|
return [u.strip() for u in v.split(",") if u.strip()]
|
||||||
|
return list(v)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# History & retention
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
history_retention_days: int = Field(
|
||||||
|
default=30,
|
||||||
|
ge=1,
|
||||||
|
le=365,
|
||||||
|
description="How many days of request history to keep",
|
||||||
|
)
|
||||||
|
provider_stats_cache_seconds: int = Field(
|
||||||
|
default=30,
|
||||||
|
ge=5,
|
||||||
|
le=3600,
|
||||||
|
description="Cache duration for live provider stats",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Auth — bearer token (legacy) + Keycloak JWT (preferred)
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
api_tokens: Annotated[frozenset[str] | None, NoDecode] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Bearer tokens for dashboard API (comma-separated)",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keycloak SSO. When `keycloak_url` is set, JWT validation is enabled
|
||||||
|
# alongside the legacy bearer token check. JWTs win first; if absent, the
|
||||||
|
# request falls back to the legacy bearer dance.
|
||||||
|
keycloak_url: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="Keycloak base URL (e.g., https://sso.clossers.com). Empty disables JWT auth.",
|
||||||
|
)
|
||||||
|
keycloak_realm: str = Field(
|
||||||
|
default="didi-clients",
|
||||||
|
description="Keycloak realm",
|
||||||
|
)
|
||||||
|
keycloak_client_id: str = Field(
|
||||||
|
default="ai-platform-dashboard",
|
||||||
|
description="Keycloak client_id (audience claim) for this dashboard",
|
||||||
|
)
|
||||||
|
keycloak_required_role: str = Field(
|
||||||
|
default="admin",
|
||||||
|
description=(
|
||||||
|
"Realm role required to access this dashboard. Default `admin` matches "
|
||||||
|
"the same role used by DIDI admin-dashboard, so a single Keycloak admin "
|
||||||
|
"role grants access to both. Override via env if you want a separate role."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
staging_mode: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"When true, all auth checks pass — for local dev only. "
|
||||||
|
"MUST be false in production."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@field_validator("api_tokens", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def parse_api_tokens(cls, v: str | list[str] | None) -> frozenset[str] | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
if isinstance(v, str):
|
||||||
|
tokens = [t.strip() for t in v.split(",") if t.strip()]
|
||||||
|
return frozenset(tokens) if tokens else None
|
||||||
|
return frozenset(v) if v else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_enabled(self) -> bool:
|
||||||
|
return bool(self.api_tokens)
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# Observability
|
||||||
|
# ==========================================================================
|
||||||
|
|
||||||
|
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(
|
||||||
|
default="INFO"
|
||||||
|
)
|
||||||
|
log_json: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsCache:
|
||||||
|
"""Thread-safe settings cache."""
|
||||||
|
|
||||||
|
_instance: DashboardSettings | None = None
|
||||||
|
_lock: threading.Lock = threading.Lock()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get(cls) -> DashboardSettings:
|
||||||
|
with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = DashboardSettings()
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def clear(cls) -> None:
|
||||||
|
with cls._lock:
|
||||||
|
cls._instance = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def set(cls, settings: DashboardSettings) -> None:
|
||||||
|
with cls._lock:
|
||||||
|
cls._instance = settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_settings() -> DashboardSettings:
|
||||||
|
return SettingsCache.get()
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Database layer."""
|
||||||
291
ai_platform/modules/dashboard/src/dashboard/db/models.py
Normal file
291
ai_platform/modules/dashboard/src/dashboard/db/models.py
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
"""SQLAlchemy models."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
BigInteger,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
Numeric,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""Base class for all models."""
|
||||||
|
|
||||||
|
|
||||||
|
class RequestHistory(Base):
|
||||||
|
"""Individual request log (30-day rolling)."""
|
||||||
|
|
||||||
|
__tablename__ = "request_history"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
request_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Request metadata
|
||||||
|
# `module` distinguishes which AI platform service emitted the event:
|
||||||
|
# web — fact-checking module (DashboardEventSink in modules/web/)
|
||||||
|
# brain — knowledge graph + cache module (modules/didi_brain/)
|
||||||
|
# agent_v3 — backend orchestrator (TypeScript)
|
||||||
|
# `tier` is web-flavored (free/premium) — for non-web modules it stays "n/a".
|
||||||
|
module: Mapped[str] = mapped_column(String(16), nullable=False, default="web")
|
||||||
|
tier: Mapped[str] = mapped_column(String(16), nullable=False, default="free")
|
||||||
|
endpoint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
provider: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
|
||||||
|
# Query content
|
||||||
|
claim: Mapped[str | None] = mapped_column(Text)
|
||||||
|
query: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
duration_ms: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
|
||||||
|
# Results
|
||||||
|
status_code: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
results_count: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
evidence_count: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
# Costs
|
||||||
|
cost_usd: Mapped[float | None] = mapped_column(Numeric(12, 6))
|
||||||
|
|
||||||
|
# User tracking (from backend)
|
||||||
|
user_id: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
|
||||||
|
# Stages breakdown (for gather endpoint)
|
||||||
|
stages: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||||
|
|
||||||
|
# Raw data (trimmed)
|
||||||
|
raw_request: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||||
|
raw_response: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_history_created", created_at.desc()),
|
||||||
|
Index("ix_history_tier_created", tier, created_at.desc()),
|
||||||
|
Index("ix_history_provider", provider, created_at.desc()),
|
||||||
|
Index("ix_history_endpoint", endpoint, created_at.desc()),
|
||||||
|
Index("ix_history_module_created", module, created_at.desc()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderStatsHourly(Base):
|
||||||
|
"""Hourly rollups for fast dashboard queries."""
|
||||||
|
|
||||||
|
__tablename__ = "provider_stats_hourly"
|
||||||
|
|
||||||
|
provider: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||||
|
hour: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True)
|
||||||
|
|
||||||
|
request_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
error_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
avg_duration_ms: Mapped[float] = mapped_column(
|
||||||
|
Numeric(12, 2), default=0, nullable=False
|
||||||
|
)
|
||||||
|
total_cost_usd: Mapped[float] = mapped_column(
|
||||||
|
Numeric(12, 6), default=0, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigOverride(Base):
|
||||||
|
"""Runtime configuration overrides."""
|
||||||
|
|
||||||
|
__tablename__ = "config_overrides"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
value: Mapped[Any] = mapped_column(JSON, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_by: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
description: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigSchemaOverride(Base):
|
||||||
|
"""Runtime schema entries — augments hardcoded KNOWN_KEYS without redeploy.
|
||||||
|
|
||||||
|
Each row represents a config key registered at runtime. The metadata JSON
|
||||||
|
matches the same shape as hardcoded entries: type, default, module,
|
||||||
|
category, label, description, restart_required, plus optional min/max/options.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "config_schema_override"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||||
|
metadata_json: Mapped[Any] = mapped_column(JSON, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
"""Dashboard users (for auth + audit log)."""
|
||||||
|
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
role: Mapped[str] = mapped_column(String(16), default="admin")
|
||||||
|
|
||||||
|
|
||||||
|
class AuditLog(Base):
|
||||||
|
"""Audit log for config changes."""
|
||||||
|
|
||||||
|
__tablename__ = "audit_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
timestamp: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
username: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
action: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
target: Mapped[str | None] = mapped_column(String(256))
|
||||||
|
old_value: Mapped[Any] = mapped_column(JSON)
|
||||||
|
new_value: Mapped[Any] = mapped_column(JSON)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_audit_timestamp", timestamp.desc()),)
|
||||||
|
|
||||||
|
|
||||||
|
class CatalogEntry(Base):
|
||||||
|
"""DB-backed catalog of AI models / extractors (caiet: Modul Dashboard).
|
||||||
|
|
||||||
|
Satisfies the requirement to administer "modele și extractoare conform datelor
|
||||||
|
din baza de date" with full CRUD: capabilities, context length, CPU/GPU
|
||||||
|
support, quantization, endpoint, limits and per-token cost. Seeded/refreshed
|
||||||
|
from each service's ``/v1/info`` but editable independently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "catalog_entry"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="model")
|
||||||
|
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
display_name: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
# Owning service: llm / embeddings / rerank / audio / video / extractors / web
|
||||||
|
service: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||||
|
|
||||||
|
capabilities: Mapped[Any | None] = mapped_column(JSON)
|
||||||
|
context_length: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
supports_cpu: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
supports_gpu: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
quantization: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
endpoint: Mapped[str | None] = mapped_column(String(256))
|
||||||
|
limits: Mapped[Any | None] = mapped_column(JSON)
|
||||||
|
cost_per_token: Mapped[float | None] = mapped_column(Numeric(16, 10))
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_by: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_catalog_kind_service", kind, service),
|
||||||
|
Index("uq_catalog_service_name", service, name, unique=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Archive tables (for future claims-api module)
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimsArchive(Base):
|
||||||
|
"""Permanent storage for verified claims."""
|
||||||
|
|
||||||
|
__tablename__ = "claims_archive"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
claim: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
claim_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
source_request_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
|
||||||
|
# Verdict
|
||||||
|
verdict: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
confidence: Mapped[float | None] = mapped_column(Numeric(5, 4))
|
||||||
|
summary: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
# Context
|
||||||
|
primary_country: Mapped[str | None] = mapped_column(String(8))
|
||||||
|
detected_language: Mapped[str | None] = mapped_column(String(8))
|
||||||
|
entities: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||||
|
|
||||||
|
# Metadata
|
||||||
|
promoted_by: Mapped[str | None] = mapped_column(String(128))
|
||||||
|
tags: Mapped[list[str] | None] = mapped_column(JSON)
|
||||||
|
|
||||||
|
articles: Mapped[list["ClaimArticle"]] = relationship(
|
||||||
|
back_populates="claim_ref", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ArticlesArchive(Base):
|
||||||
|
"""Permanent storage for article full-text."""
|
||||||
|
|
||||||
|
__tablename__ = "articles_archive"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||||
|
url: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||||
|
url_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
canonical_url: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
title: Mapped[str | None] = mapped_column(Text)
|
||||||
|
full_text: Mapped[str | None] = mapped_column(Text)
|
||||||
|
publisher: Mapped[str | None] = mapped_column(String(256))
|
||||||
|
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
retrieved_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
extraction_method: Mapped[str | None] = mapped_column(String(16))
|
||||||
|
credibility_score: Mapped[float | None] = mapped_column(Numeric(5, 4))
|
||||||
|
|
||||||
|
claims: Mapped[list["ClaimArticle"]] = relationship(back_populates="article_ref")
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_articles_url_hash", url_hash),)
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimArticle(Base):
|
||||||
|
"""Many-to-many: claims to articles with relevance."""
|
||||||
|
|
||||||
|
__tablename__ = "claim_articles"
|
||||||
|
|
||||||
|
claim_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("claims_archive.id"), primary_key=True
|
||||||
|
)
|
||||||
|
article_id: Mapped[int] = mapped_column(
|
||||||
|
BigInteger, ForeignKey("articles_archive.id"), primary_key=True
|
||||||
|
)
|
||||||
|
relevance_score: Mapped[float | None] = mapped_column(Numeric(5, 4))
|
||||||
|
snippet: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|
||||||
|
claim_ref: Mapped["ClaimsArchive"] = relationship(back_populates="articles")
|
||||||
|
article_ref: Mapped["ArticlesArchive"] = relationship(back_populates="claims")
|
||||||
74
ai_platform/modules/dashboard/src/dashboard/db/session.py
Normal file
74
ai_platform/modules/dashboard/src/dashboard/db/session.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""Database session management."""
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from dashboard.config import DashboardSettings
|
||||||
|
from dashboard.db.models import Base
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("db.session")
|
||||||
|
|
||||||
|
_engine: AsyncEngine | None = None
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_engine(settings: DashboardSettings) -> AsyncEngine:
|
||||||
|
"""Create the async engine and session factory."""
|
||||||
|
global _engine, _session_factory
|
||||||
|
|
||||||
|
_engine = create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=False,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
)
|
||||||
|
_session_factory = async_sessionmaker(
|
||||||
|
_engine, class_=AsyncSession, expire_on_commit=False
|
||||||
|
)
|
||||||
|
logger.info("Database engine initialized")
|
||||||
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
async def create_all_tables() -> None:
|
||||||
|
"""Create all tables if they don't exist."""
|
||||||
|
if _engine is None:
|
||||||
|
raise RuntimeError("Engine not initialized")
|
||||||
|
async with _engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
logger.info("Database tables ensured")
|
||||||
|
|
||||||
|
|
||||||
|
async def close_engine() -> None:
|
||||||
|
"""Close the database engine."""
|
||||||
|
global _engine, _session_factory
|
||||||
|
if _engine is not None:
|
||||||
|
await _engine.dispose()
|
||||||
|
_engine = None
|
||||||
|
_session_factory = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
"""FastAPI dependency that yields a session."""
|
||||||
|
if _session_factory is None:
|
||||||
|
raise RuntimeError("Session factory not initialized")
|
||||||
|
async with _session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||||
|
"""Return the configured session factory."""
|
||||||
|
if _session_factory is None:
|
||||||
|
raise RuntimeError("Session factory not initialized")
|
||||||
|
return _session_factory
|
||||||
33
ai_platform/modules/dashboard/src/dashboard/logging.py
Normal file
33
ai_platform/modules/dashboard/src/dashboard/logging.py
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""Logging configuration."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str = "INFO", json_output: bool = False) -> None:
|
||||||
|
"""Configure root logger."""
|
||||||
|
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||||
|
formatter: logging.Formatter
|
||||||
|
if json_output:
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
'{"time":"%(asctime)s","level":"%(levelname)s",'
|
||||||
|
'"logger":"%(name)s","msg":"%(message)s"}'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||||
|
)
|
||||||
|
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(formatter)
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.handlers = [handler]
|
||||||
|
root.setLevel(log_level)
|
||||||
|
|
||||||
|
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
"""Get named logger."""
|
||||||
|
return logging.getLogger(f"dashboard.{name}")
|
||||||
59
ai_platform/modules/dashboard/src/dashboard/pricing.py
Normal file
59
ai_platform/modules/dashboard/src/dashboard/pricing.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
"""Per-provider pricing heuristics for cost estimation.
|
||||||
|
|
||||||
|
These rates are approximations used to compute an estimated cost per
|
||||||
|
request at ingest time. Real billing lives on the provider side — the
|
||||||
|
dashboard just needs a "good enough" number for internal reporting.
|
||||||
|
|
||||||
|
Pricing cheat-sheet (as of 2026-04):
|
||||||
|
SerpAPI Starter: $25/month / 1000 searches = $0.025 per search
|
||||||
|
Tavily: free tier = $0
|
||||||
|
Brave: free (Data for AI tier) = $0
|
||||||
|
LinkUp: pay-as-you-go, unknown = $0 (placeholder)
|
||||||
|
SearXNG: self-hosted = $0
|
||||||
|
OpenRouter (gemini-3.1-flash-lite-preview):
|
||||||
|
$0.25/M input, $1.50/M output
|
||||||
|
— a typical gather uses ~4k input + ~800 output tokens
|
||||||
|
≈ $0.0022 per gather
|
||||||
|
|
||||||
|
Keep this module free of IO so it can be imported in routes and tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Per-request cost in USD for search providers
|
||||||
|
SEARCH_COST_USD: dict[str, float] = {
|
||||||
|
"serpapi": 0.025,
|
||||||
|
"tavily": 0.0,
|
||||||
|
"brave": 0.0,
|
||||||
|
"linkup": 0.0,
|
||||||
|
"exa": 0.01, # rough — pay-as-you-go
|
||||||
|
"searxng": 0.0,
|
||||||
|
"paid-rotation": 0.025, # worst-case assume SerpAPI was rotated
|
||||||
|
}
|
||||||
|
|
||||||
|
# Per-request cost in USD for full gather pipelines (includes context + evidence LLM)
|
||||||
|
GATHER_LLM_COST_USD: dict[str, float] = {
|
||||||
|
"free": 0.0, # runs on local Qwen
|
||||||
|
"premium": 0.003, # ~3 OpenRouter calls (context + evidence + summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_search_cost(provider: str | None) -> float:
|
||||||
|
"""Return estimated cost for a single /v1/search call."""
|
||||||
|
if provider is None:
|
||||||
|
return 0.0
|
||||||
|
return SEARCH_COST_USD.get(provider.lower(), 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_gather_cost(tier: str | None, provider: str | None) -> float:
|
||||||
|
"""Return estimated cost for a full /v1/gather pipeline."""
|
||||||
|
search = estimate_search_cost(provider)
|
||||||
|
llm = GATHER_LLM_COST_USD.get((tier or "free").lower(), 0.0)
|
||||||
|
return round(search + llm, 6)
|
||||||
|
|
||||||
|
|
||||||
|
def estimate_cost(endpoint: str, tier: str | None, provider: str | None) -> float:
|
||||||
|
"""Dispatch by endpoint."""
|
||||||
|
if endpoint == "/v1/gather":
|
||||||
|
return estimate_gather_cost(tier, provider)
|
||||||
|
if endpoint == "/v1/search":
|
||||||
|
return estimate_search_cost(provider)
|
||||||
|
return 0.0
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
"""Live provider stats fetchers."""
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
"""Base types for provider stats."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProviderStats:
|
||||||
|
"""Live stats for a single provider."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
display_name: str
|
||||||
|
kind: str # "search" | "llm" | "internal"
|
||||||
|
healthy: bool
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
# Quota / billing
|
||||||
|
plan_name: str | None = None
|
||||||
|
plan_price_monthly: float | None = None
|
||||||
|
quota_limit: float | None = None
|
||||||
|
quota_used: float | None = None
|
||||||
|
quota_unit: str = "requests" # "requests" | "usd" | "credits"
|
||||||
|
quota_period: str = "month"
|
||||||
|
|
||||||
|
# Rate limits
|
||||||
|
rate_limit: str | None = None
|
||||||
|
|
||||||
|
# Last error
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
# Extra provider-specific fields
|
||||||
|
extra: dict[str, str] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quota_remaining(self) -> float | None:
|
||||||
|
if self.quota_limit is None or self.quota_used is None:
|
||||||
|
return None
|
||||||
|
return max(0.0, self.quota_limit - self.quota_used)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quota_percent_used(self) -> float | None:
|
||||||
|
if self.quota_limit is None or self.quota_used is None or self.quota_limit == 0:
|
||||||
|
return None
|
||||||
|
return round((self.quota_used / self.quota_limit) * 100, 1)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"display_name": self.display_name,
|
||||||
|
"kind": self.kind,
|
||||||
|
"healthy": self.healthy,
|
||||||
|
"message": self.message,
|
||||||
|
"plan_name": self.plan_name,
|
||||||
|
"plan_price_monthly": self.plan_price_monthly,
|
||||||
|
"quota_limit": self.quota_limit,
|
||||||
|
"quota_used": self.quota_used,
|
||||||
|
"quota_remaining": self.quota_remaining,
|
||||||
|
"quota_percent_used": self.quota_percent_used,
|
||||||
|
"quota_unit": self.quota_unit,
|
||||||
|
"quota_period": self.quota_period,
|
||||||
|
"rate_limit": self.rate_limit,
|
||||||
|
"last_error": self.last_error,
|
||||||
|
"extra": self.extra,
|
||||||
|
"checked_at": datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""Brave Search live stats fetcher."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from dashboard.providers.base import ProviderStats
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_brave_stats(api_key: str | None) -> ProviderStats:
|
||||||
|
stats = ProviderStats(
|
||||||
|
name="brave",
|
||||||
|
display_name="Brave Search",
|
||||||
|
kind="search",
|
||||||
|
healthy=False,
|
||||||
|
)
|
||||||
|
if not api_key:
|
||||||
|
stats.message = "API key not configured"
|
||||||
|
return stats
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
# Brave has no dedicated account endpoint — probe with rate limit headers
|
||||||
|
resp = await client.get(
|
||||||
|
"https://api.search.brave.com/res/v1/web/search",
|
||||||
|
params={"q": "test", "count": 1},
|
||||||
|
headers={"X-Subscription-Token": api_key},
|
||||||
|
)
|
||||||
|
|
||||||
|
stats.healthy = resp.status_code == 200
|
||||||
|
headers = resp.headers
|
||||||
|
|
||||||
|
# Parse rate limit headers (format: "second, month")
|
||||||
|
rl_limit = headers.get("x-ratelimit-limit", "")
|
||||||
|
rl_remaining = headers.get("x-ratelimit-remaining", "")
|
||||||
|
|
||||||
|
if rl_limit:
|
||||||
|
parts = [p.strip() for p in rl_limit.split(",")]
|
||||||
|
if len(parts) >= 1:
|
||||||
|
stats.rate_limit = f"{parts[0]}/second"
|
||||||
|
|
||||||
|
if rl_remaining and rl_limit:
|
||||||
|
rem_parts = [p.strip() for p in rl_remaining.split(",")]
|
||||||
|
lim_parts = [p.strip() for p in rl_limit.split(",")]
|
||||||
|
if len(rem_parts) >= 2 and len(lim_parts) >= 2:
|
||||||
|
try:
|
||||||
|
monthly_limit = float(lim_parts[1])
|
||||||
|
monthly_remaining = float(rem_parts[1])
|
||||||
|
if monthly_limit > 0:
|
||||||
|
stats.quota_limit = monthly_limit
|
||||||
|
stats.quota_used = monthly_limit - monthly_remaining
|
||||||
|
stats.quota_unit = "requests"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
stats.plan_name = "Free / Data for AI"
|
||||||
|
stats.extra = {
|
||||||
|
"rate_limit_raw": rl_limit,
|
||||||
|
"remaining_raw": rl_remaining,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
stats.last_error = str(e)
|
||||||
|
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""Internal service health checks (SearXNG, vLLM, llama.cpp, web-api)."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from dashboard.providers.base import ProviderStats
|
||||||
|
|
||||||
|
|
||||||
|
async def _probe(url: str, timeout: float = 5.0) -> tuple[bool, str | None]:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
return resp.status_code == 200, None
|
||||||
|
except Exception as e:
|
||||||
|
return False, f"{type(e).__name__}: {e}"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_searxng_stats(base_url: str) -> ProviderStats:
|
||||||
|
stats = ProviderStats(
|
||||||
|
name="searxng",
|
||||||
|
display_name="SearXNG",
|
||||||
|
kind="search",
|
||||||
|
healthy=False,
|
||||||
|
plan_name="Self-hosted",
|
||||||
|
quota_limit=None,
|
||||||
|
quota_used=None,
|
||||||
|
)
|
||||||
|
healthy, err = await _probe(f"{base_url}/healthz")
|
||||||
|
stats.healthy = healthy
|
||||||
|
if not healthy:
|
||||||
|
stats.last_error = err
|
||||||
|
stats.message = "Unreachable"
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_web_api_stats(base_url: str) -> ProviderStats:
|
||||||
|
stats = ProviderStats(
|
||||||
|
name="web_api",
|
||||||
|
display_name="Web API",
|
||||||
|
kind="internal",
|
||||||
|
healthy=False,
|
||||||
|
)
|
||||||
|
healthy, err = await _probe(f"{base_url}/health")
|
||||||
|
stats.healthy = healthy
|
||||||
|
if not healthy:
|
||||||
|
stats.last_error = err
|
||||||
|
stats.message = "Unreachable"
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_vllm_qwen_stats(base_url: str) -> ProviderStats:
|
||||||
|
stats = ProviderStats(
|
||||||
|
name="vllm_qwen",
|
||||||
|
display_name="Qwen3.5-35B-A3B (local vLLM)",
|
||||||
|
kind="llm",
|
||||||
|
healthy=False,
|
||||||
|
plan_name="Local GPU",
|
||||||
|
)
|
||||||
|
healthy, err = await _probe(f"{base_url}/v1/models")
|
||||||
|
stats.healthy = healthy
|
||||||
|
if not healthy:
|
||||||
|
stats.last_error = err
|
||||||
|
stats.message = "Unreachable"
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_llamacpp_stats(urls: list[str]) -> list[ProviderStats]:
|
||||||
|
results = []
|
||||||
|
for i, url in enumerate(urls, 1):
|
||||||
|
stats = ProviderStats(
|
||||||
|
name=f"llamacpp_{i}",
|
||||||
|
display_name=f"llama.cpp #{i} ({url})",
|
||||||
|
kind="llm",
|
||||||
|
healthy=False,
|
||||||
|
plan_name="External GPU",
|
||||||
|
)
|
||||||
|
healthy, err = await _probe(f"{url}/v1/models")
|
||||||
|
stats.healthy = healthy
|
||||||
|
if healthy:
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
resp = await client.get(f"{url}/v1/models")
|
||||||
|
data = resp.json()
|
||||||
|
models = data.get("models") or data.get("data") or []
|
||||||
|
if models:
|
||||||
|
first = models[0]
|
||||||
|
stats.extra = {"model": str(first.get("name") or first.get("id", ""))}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
stats.last_error = err
|
||||||
|
stats.message = "Unreachable"
|
||||||
|
results.append(stats)
|
||||||
|
return results
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""LinkUp live stats (limited — no billing endpoint)."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from dashboard.providers.base import ProviderStats
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_linkup_stats(api_key: str | None) -> ProviderStats:
|
||||||
|
stats = ProviderStats(
|
||||||
|
name="linkup",
|
||||||
|
display_name="LinkUp",
|
||||||
|
kind="search",
|
||||||
|
healthy=False,
|
||||||
|
)
|
||||||
|
if not api_key:
|
||||||
|
stats.message = "API key not configured"
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# LinkUp has no public billing endpoint; we only probe health
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"https://api.linkup.so/v1/search",
|
||||||
|
json={"q": "test", "depth": "standard", "outputType": "searchResults"},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
stats.healthy = resp.status_code == 200
|
||||||
|
stats.message = None if stats.healthy else f"HTTP {resp.status_code}"
|
||||||
|
stats.plan_name = "Standard"
|
||||||
|
except Exception as e:
|
||||||
|
stats.last_error = str(e)
|
||||||
|
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""OpenRouter live credits fetcher."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from dashboard.providers.base import ProviderStats
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_openrouter_stats(api_key: str | None) -> ProviderStats:
|
||||||
|
stats = ProviderStats(
|
||||||
|
name="openrouter",
|
||||||
|
display_name="OpenRouter",
|
||||||
|
kind="llm",
|
||||||
|
healthy=False,
|
||||||
|
)
|
||||||
|
if not api_key:
|
||||||
|
stats.message = "API key not configured"
|
||||||
|
return stats
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"https://openrouter.ai/api/v1/credits",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json().get("data", {})
|
||||||
|
|
||||||
|
stats.healthy = True
|
||||||
|
stats.quota_limit = float(data.get("total_credits") or 0)
|
||||||
|
stats.quota_used = float(data.get("total_usage") or 0)
|
||||||
|
stats.quota_unit = "usd"
|
||||||
|
stats.quota_period = "total"
|
||||||
|
stats.plan_name = "Pay-as-you-go"
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
stats.last_error = str(e)
|
||||||
|
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_openrouter_models(api_key: str | None) -> list[dict]:
|
||||||
|
"""Fetch available models from OpenRouter for config dropdown."""
|
||||||
|
if not api_key:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"https://openrouter.ai/api/v1/models",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json().get("data", [])
|
||||||
|
|
||||||
|
models: list[dict] = []
|
||||||
|
for m in data:
|
||||||
|
pricing = m.get("pricing", {})
|
||||||
|
try:
|
||||||
|
prompt_per_m = float(pricing.get("prompt", "0")) * 1_000_000
|
||||||
|
completion_per_m = float(pricing.get("completion", "0")) * 1_000_000
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
prompt_per_m = 0.0
|
||||||
|
completion_per_m = 0.0
|
||||||
|
models.append(
|
||||||
|
{
|
||||||
|
"id": m.get("id"),
|
||||||
|
"name": m.get("name", m.get("id")),
|
||||||
|
"context_length": m.get("context_length", 0),
|
||||||
|
"prompt_cost_per_m": round(prompt_per_m, 2),
|
||||||
|
"completion_cost_per_m": round(completion_per_m, 2),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return models
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
"""Provider registry — aggregates all live stats with caching."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from dashboard.config import DashboardSettings
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
from dashboard.providers.base import ProviderStats
|
||||||
|
from dashboard.providers.brave import fetch_brave_stats
|
||||||
|
from dashboard.providers.internal import (
|
||||||
|
fetch_llamacpp_stats,
|
||||||
|
fetch_searxng_stats,
|
||||||
|
fetch_vllm_qwen_stats,
|
||||||
|
fetch_web_api_stats,
|
||||||
|
)
|
||||||
|
from dashboard.providers.linkup import fetch_linkup_stats
|
||||||
|
from dashboard.providers.openrouter import fetch_openrouter_stats
|
||||||
|
from dashboard.providers.serpapi import fetch_serpapi_stats
|
||||||
|
from dashboard.providers.tavily import fetch_tavily_stats
|
||||||
|
|
||||||
|
logger = get_logger("providers.registry")
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderRegistry:
|
||||||
|
"""Caches provider stats with configurable TTL."""
|
||||||
|
|
||||||
|
def __init__(self, settings: DashboardSettings) -> None:
|
||||||
|
self.settings = settings
|
||||||
|
self._cache: dict[str, ProviderStats] = {}
|
||||||
|
self._cache_time: float = 0.0
|
||||||
|
self._cache_wall: datetime | None = None
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_refresh_iso(self) -> str | None:
|
||||||
|
return self._cache_wall.isoformat() if self._cache_wall else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def age_seconds(self) -> float | None:
|
||||||
|
if not self._cache_wall:
|
||||||
|
return None
|
||||||
|
return (datetime.now(timezone.utc) - self._cache_wall).total_seconds()
|
||||||
|
|
||||||
|
async def get_all(self, force: bool = False) -> list[ProviderStats]:
|
||||||
|
"""Fetch all provider stats (cached)."""
|
||||||
|
async with self._lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
age = now - self._cache_time
|
||||||
|
if (
|
||||||
|
not force
|
||||||
|
and self._cache
|
||||||
|
and age < self.settings.provider_stats_cache_seconds
|
||||||
|
):
|
||||||
|
return list(self._cache.values())
|
||||||
|
|
||||||
|
results = await self._fetch_all()
|
||||||
|
self._cache = {s.name: s for s in results}
|
||||||
|
self._cache_time = now
|
||||||
|
self._cache_wall = datetime.now(timezone.utc)
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def _fetch_all(self) -> list[ProviderStats]:
|
||||||
|
tasks = [
|
||||||
|
fetch_serpapi_stats(self.settings.serpapi_api_key),
|
||||||
|
fetch_tavily_stats(self.settings.tavily_api_key),
|
||||||
|
fetch_brave_stats(self.settings.brave_api_key),
|
||||||
|
fetch_linkup_stats(self.settings.linkup_api_key),
|
||||||
|
fetch_openrouter_stats(self.settings.openrouter_api_key),
|
||||||
|
fetch_searxng_stats(self.settings.searxng_url),
|
||||||
|
fetch_web_api_stats(self.settings.web_api_url),
|
||||||
|
fetch_vllm_qwen_stats(self.settings.vllm_qwen_url),
|
||||||
|
]
|
||||||
|
|
||||||
|
gathered = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
|
stats: list[ProviderStats] = []
|
||||||
|
for i, r in enumerate(gathered):
|
||||||
|
if isinstance(r, BaseException):
|
||||||
|
logger.warning("Provider fetch #%d failed: %s", i, r)
|
||||||
|
continue
|
||||||
|
stats.append(r) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
# llama.cpp servers (variable count)
|
||||||
|
try:
|
||||||
|
llamacpp = await fetch_llamacpp_stats(self.settings.llamacpp_urls)
|
||||||
|
stats.extend(llamacpp)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("llama.cpp fetch failed: %s", e)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
"""SerpAPI live quota fetcher."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from dashboard.providers.base import ProviderStats
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_serpapi_stats(api_key: str | None) -> ProviderStats:
|
||||||
|
stats = ProviderStats(
|
||||||
|
name="serpapi",
|
||||||
|
display_name="SerpAPI",
|
||||||
|
kind="search",
|
||||||
|
healthy=False,
|
||||||
|
)
|
||||||
|
if not api_key:
|
||||||
|
stats.message = "API key not configured"
|
||||||
|
return stats
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"https://serpapi.com/account",
|
||||||
|
params={"api_key": api_key},
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
stats.healthy = data.get("account_status", "").lower() == "active"
|
||||||
|
stats.plan_name = data.get("plan_name")
|
||||||
|
stats.plan_price_monthly = float(data.get("plan_monthly_price") or 0)
|
||||||
|
stats.quota_limit = float(data.get("searches_per_month") or 0)
|
||||||
|
stats.quota_used = float(data.get("this_month_usage") or 0)
|
||||||
|
stats.quota_unit = "searches"
|
||||||
|
stats.rate_limit = f"{data.get('account_rate_limit_per_hour', 0)}/hour"
|
||||||
|
stats.extra = {
|
||||||
|
"email": str(data.get("account_email", "")),
|
||||||
|
"extra_credits": str(data.get("extra_credits", 0)),
|
||||||
|
"this_hour": str(data.get("this_hour_searches", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
stats.last_error = str(e)
|
||||||
|
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
"""Tavily live usage fetcher."""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from dashboard.providers.base import ProviderStats
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_tavily_stats(api_key: str | None) -> ProviderStats:
|
||||||
|
stats = ProviderStats(
|
||||||
|
name="tavily",
|
||||||
|
display_name="Tavily",
|
||||||
|
kind="search",
|
||||||
|
healthy=False,
|
||||||
|
)
|
||||||
|
if not api_key:
|
||||||
|
stats.message = "API key not configured"
|
||||||
|
return stats
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
resp = await client.get(
|
||||||
|
"https://api.tavily.com/usage",
|
||||||
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Tavily fronts api.tavily.com with AWS WAF; some IPs / regions get a
|
||||||
|
# 202 challenge with an empty body (header x-amzn-waf-action=challenge).
|
||||||
|
# That's not a JSON error — the API just refused us. Surface it clearly
|
||||||
|
# instead of pretending the response was malformed JSON.
|
||||||
|
if resp.headers.get("x-amzn-waf-action"):
|
||||||
|
stats.message = (
|
||||||
|
f"Tavily WAF challenge ({resp.headers['x-amzn-waf-action']}) — "
|
||||||
|
"live quota probe blocked from this IP"
|
||||||
|
)
|
||||||
|
stats.last_error = stats.message
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Empty body or non-JSON content-type → can't parse, but quota itself
|
||||||
|
# may still be fine. Treat as "tracking unavailable" not a hard error.
|
||||||
|
if not resp.content:
|
||||||
|
stats.message = f"Empty body from Tavily ({resp.status_code})"
|
||||||
|
stats.last_error = stats.message
|
||||||
|
return stats
|
||||||
|
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
key_data = data.get("key", {})
|
||||||
|
account_data = data.get("account", {})
|
||||||
|
|
||||||
|
stats.healthy = True
|
||||||
|
stats.plan_name = account_data.get("current_plan")
|
||||||
|
stats.quota_limit = float(account_data.get("plan_limit") or 0)
|
||||||
|
stats.quota_used = float(account_data.get("plan_usage") or 0)
|
||||||
|
stats.quota_unit = "requests"
|
||||||
|
stats.extra = {
|
||||||
|
"search": str(account_data.get("search_usage", 0)),
|
||||||
|
"crawl": str(account_data.get("crawl_usage", 0)),
|
||||||
|
"extract": str(account_data.get("extract_usage", 0)),
|
||||||
|
"key_usage": str(key_data.get("usage", 0)),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
stats.last_error = str(e)
|
||||||
|
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||||
|
|
||||||
|
return stats
|
||||||
41
ai_platform/modules/dashboard/src/dashboard/retention.py
Normal file
41
ai_platform/modules/dashboard/src/dashboard/retention.py
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Background task for data retention."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from dashboard.config import DashboardSettings
|
||||||
|
from dashboard.db.models import RequestHistory
|
||||||
|
from dashboard.db.session import get_session_factory
|
||||||
|
from dashboard.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("retention")
|
||||||
|
|
||||||
|
|
||||||
|
async def purge_old_history(days: int) -> int:
|
||||||
|
"""Delete history rows older than N days."""
|
||||||
|
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||||
|
factory = get_session_factory()
|
||||||
|
async with factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
delete(RequestHistory).where(RequestHistory.created_at < cutoff)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return result.rowcount or 0
|
||||||
|
|
||||||
|
|
||||||
|
async def retention_loop(settings: DashboardSettings) -> None:
|
||||||
|
"""Run purge once per hour."""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
deleted = await purge_old_history(settings.history_retention_days)
|
||||||
|
if deleted:
|
||||||
|
logger.info(
|
||||||
|
"Purged %d rows older than %d days",
|
||||||
|
deleted,
|
||||||
|
settings.history_retention_days,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Retention job failed: %s", e)
|
||||||
|
await asyncio.sleep(3600)
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Archive — didiAI Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-white">Claims Archive</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">
|
||||||
|
Permanently stored claims + articles. Consumed by the future claims-api
|
||||||
|
to serve gather responses without network search.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 text-xs">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-2xl font-bold text-white">{{ total_claims }}</p>
|
||||||
|
<p class="text-slate-400">claims</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-2xl font-bold text-white">{{ total_articles }}</p>
|
||||||
|
<p class="text-slate-400">articles</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="get" class="mb-6">
|
||||||
|
<input type="text" name="q" value="{{ query }}" placeholder="Search claims..."
|
||||||
|
class="w-full bg-slate-900 text-white text-sm rounded-md border border-slate-800 px-4 py-2.5 focus:outline-none focus:border-indigo-600">
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for claim in claims %}
|
||||||
|
<a href="/archive/{{ claim.id }}" class="block bg-slate-900 rounded-xl border border-slate-800 p-4 hover:border-slate-700 transition">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-white font-medium">{{ claim.claim }}</p>
|
||||||
|
<div class="flex items-center gap-3 mt-2 text-xs text-slate-400">
|
||||||
|
{% if claim.primary_country %}<span>🌍 {{ claim.primary_country }}</span>{% endif %}
|
||||||
|
{% if claim.detected_language %}<span>💬 {{ claim.detected_language }}</span>{% endif %}
|
||||||
|
{% if claim.promoted_by %}<span>👤 {{ claim.promoted_by }}</span>{% endif %}
|
||||||
|
{% if claim.created_at %}<span>🕐 {{ claim.created_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if claim.verdict %}
|
||||||
|
<span class="inline-flex px-2 py-0.5 text-xs rounded-full bg-emerald-900/50 text-emerald-300 border border-emerald-800 shrink-0">
|
||||||
|
{{ claim.verdict }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-12 text-center text-slate-500">
|
||||||
|
No claims archived yet. Promote a gather from the <a href="/history" class="text-indigo-400 hover:text-indigo-300">History</a> page.
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Claim #{{ claim.id }} — didiAI Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/archive" class="text-sm text-slate-400 hover:text-white">← Back to archive</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-xl font-bold text-white">{{ claim.claim }}</h1>
|
||||||
|
<div class="flex items-center gap-3 mt-2 text-xs text-slate-400">
|
||||||
|
{% if claim.primary_country %}<span>🌍 {{ claim.primary_country }}</span>{% endif %}
|
||||||
|
{% if claim.detected_language %}<span>💬 {{ claim.detected_language }}</span>{% endif %}
|
||||||
|
{% if claim.promoted_by %}<span>👤 {{ claim.promoted_by }}</span>{% endif %}
|
||||||
|
{% if claim.created_at %}<span>🕐 {{ claim.created_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
|
||||||
|
{% if claim.source_request_id %}
|
||||||
|
<a href="/history/{{ claim.source_request_id }}" class="text-indigo-400 hover:text-indigo-300">source request →</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if claim.summary %}
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Summary</h2>
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-slate-200">{{ claim.summary }}</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if claim.entities %}
|
||||||
|
<section class="mb-8">
|
||||||
|
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Entities</h2>
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-xs text-slate-300 grid grid-cols-3 gap-4">
|
||||||
|
{% for key in ['persons', 'institutions', 'locations'] %}
|
||||||
|
<div>
|
||||||
|
<p class="uppercase text-slate-500 font-semibold mb-1">{{ key }}</p>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
{% for item in (claim.entities.get(key) or []) %}<li>{{ item }}</li>{% else %}<li class="text-slate-600">—</li>{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Articles ({{ articles|length }})</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for link in articles %}
|
||||||
|
{% set a = link.article %}
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||||
|
<div class="flex items-start justify-between gap-4 mb-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<a href="{{ a.url }}" target="_blank" rel="noopener" class="text-white font-medium hover:text-indigo-300">{{ a.title or a.url }}</a>
|
||||||
|
<p class="text-xs text-slate-500 mt-0.5 truncate">{{ a.publisher or a.url }}</p>
|
||||||
|
</div>
|
||||||
|
{% if link.link.relevance_score %}
|
||||||
|
<span class="shrink-0 text-xs font-mono text-slate-400">rel: {{ '%.2f'|format(link.link.relevance_score|float) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if a.credibility_score %}
|
||||||
|
<span class="shrink-0 text-xs font-mono text-slate-400">cred: {{ '%.2f'|format(a.credibility_score|float) }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if link.link.snippet %}
|
||||||
|
<p class="text-xs text-slate-300 italic">"{{ link.link.snippet[:300] }}{% if link.link.snippet|length > 300 %}…{% endif %}"</p>
|
||||||
|
{% endif %}
|
||||||
|
{% if a.full_text %}
|
||||||
|
<details class="mt-2 text-xs text-slate-400">
|
||||||
|
<summary class="cursor-pointer hover:text-white">View full text ({{ a.full_text|length }} chars)</summary>
|
||||||
|
<pre class="mt-2 p-3 bg-slate-800/50 rounded whitespace-pre-wrap text-[11px]">{{ a.full_text[:3000] }}{% if a.full_text|length > 3000 %}…{% endif %}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Audit Log — didiAI Dashboard{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-white">Audit Log</h1>
|
||||||
|
<p class="mt-1 text-sm text-slate-400">Who changed what, when. Covers config changes and auth events.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||||
|
<div class="overflow-x-auto scrollbar-thin">
|
||||||
|
<table class="min-w-full divide-y divide-slate-800">
|
||||||
|
<thead class="bg-slate-800/50">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Time</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">User</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Action</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Target</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">From</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">To</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-slate-800 text-sm">
|
||||||
|
{% for entry in entries %}
|
||||||
|
<tr class="hover:bg-slate-800/30">
|
||||||
|
<td class="px-4 py-3 text-slate-400 font-mono text-xs whitespace-nowrap">
|
||||||
|
{{ entry.timestamp.strftime('%m-%d %H:%M:%S') if entry.timestamp else '—' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-white font-medium">{{ entry.username }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% set action_color = {
|
||||||
|
'config.set': 'bg-indigo-900/50 text-indigo-300 border-indigo-800',
|
||||||
|
'config.reset': 'bg-amber-900/50 text-amber-300 border-amber-800',
|
||||||
|
'config.delete': 'bg-rose-900/50 text-rose-300 border-rose-800',
|
||||||
|
} %}
|
||||||
|
<span class="inline-flex px-2 py-0.5 text-xs rounded-full border {{ action_color.get(entry.action, 'bg-slate-800 text-slate-300 border-slate-700') }}">
|
||||||
|
{{ entry.action }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-slate-300">{{ entry.target or '—' }}</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ entry.old_value|tojson if entry.old_value is not none else '—' }}</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-slate-300">{{ entry.new_value|tojson if entry.new_value is not none else '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="6" class="px-4 py-12 text-center text-slate-500">No audit entries yet.</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-full bg-slate-950">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}didiAI Dashboard{% endblock %}</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
<script defer src="https://unpkg.com/alpinejs@3.14.3/dist/cdn.min.js"></script>
|
||||||
|
<style>
|
||||||
|
body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||||
|
.scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track { background: #1e293b; }
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="h-full text-slate-200">
|
||||||
|
<div class="min-h-full">
|
||||||
|
<nav class="bg-slate-900 border-b border-slate-800">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex h-16 items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0 flex items-center gap-2">
|
||||||
|
<div class="w-8 h-8 rounded bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center font-bold">d</div>
|
||||||
|
<span class="text-white font-semibold tracking-tight">didiAI Dashboard</span>
|
||||||
|
</div>
|
||||||
|
<div class="ml-10 flex items-baseline space-x-2">
|
||||||
|
{% set nav_items = [
|
||||||
|
('overview', '/', 'Overview'),
|
||||||
|
('providers', '/providers', 'Providers'),
|
||||||
|
('history', '/history', 'History'),
|
||||||
|
('archive', '/archive', 'Archive'),
|
||||||
|
('cost', '/cost', 'Cost'),
|
||||||
|
('config', '/config', 'Config'),
|
||||||
|
('audit', '/audit', 'Audit'),
|
||||||
|
] %}
|
||||||
|
{% for key, href, label in nav_items %}
|
||||||
|
<a href="{{ href }}" class="rounded-md px-3 py-2 text-sm font-medium transition
|
||||||
|
{% if active_page == key %}bg-slate-800 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">
|
||||||
|
{{ label }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-slate-400">
|
||||||
|
<span class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main class="py-8">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue