Livrare LOT 1 - Didi

This commit is contained in:
Dezvoltari Evotech 2026-06-25 14:13:25 -07:00
commit 5380c3fc63
990 changed files with 133308 additions and 0 deletions

91
ai_platform/.env.example Normal file
View 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
View 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/

View 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
View 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
View 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
View 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).

View 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
View 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
View 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

View 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

View 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).

View 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.01.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.050.08x** (10s audio = 0.51s transcription, 1h audio = 35min)
- 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)

View 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)

View 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}

View 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

View 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

View 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"

View file

@ -0,0 +1,3 @@
"""Audio transcription service using faster-whisper."""
__version__ = "0.1.0"

View 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(),
)

View 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)

View 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"
)

View 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()

View 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

View 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) |

View 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.

View 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

View 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

View 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}

View 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

View 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

View 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"

View file

@ -0,0 +1,3 @@
"""Catalog API - Service Registry & Discovery."""
__version__ = "0.1.0"

View 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(),
)

View 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)

View 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()

View 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) ≈ 812 s; from then on each search is 24 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.

View 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 612 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 58 if pool starves the request queue.

View 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"]

View 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

View 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"

View file

@ -0,0 +1,3 @@
"""Cloak — stealth scraping service for Google / Bing / DuckDuckGo search results."""
__version__ = "0.1.0"

View 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)

View 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"

View 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

View 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)

View 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()

View 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"

View 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

View 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

View 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).

View 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

View 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"]

View 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

View 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

View 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

View 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"]

View file

@ -0,0 +1,3 @@
"""Dashboard module — admin UI for web search providers and costs."""
__version__ = "0.1.0"

View file

@ -0,0 +1 @@
"""FastAPI application."""

View 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()

View 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"

View 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 [])

View file

@ -0,0 +1 @@
"""API routes."""

View file

@ -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

View file

@ -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,
}

View file

@ -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"),
)

View file

@ -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}

File diff suppressed because it is too large Load diff

View file

@ -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")
}

View file

@ -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

View file

@ -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}

View file

@ -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}

View 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,
}

View 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],
}

View 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}

View 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()

View 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()

View 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()

View file

@ -0,0 +1 @@
"""Database layer."""

View 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")

View 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

View 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}")

View 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

View file

@ -0,0 +1 @@
"""Live provider stats fetchers."""

View file

@ -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(),
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 []

View file

@ -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

View file

@ -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

View file

@ -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

View 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)

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -0,0 +1,42 @@
{% extends "base.html" %}
{% block title %}Configuration — didiAI Dashboard{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">Runtime Configuration</h1>
<p class="mt-1 text-sm text-slate-400">
Live settings pulled by web-api every 30 seconds — no restart required.
Override values replace env defaults.
</p>
</div>
{% set category_info = {
'providers': {'icon': '🔌', 'title': 'Paid Providers', 'desc': 'Toggle premium-tier search engines on and off'},
'routing': {'icon': '🔀', 'title': 'Routing Strategy', 'desc': 'How premium-tier picks providers per request'},
'llm': {'icon': '🧠', 'title': 'LLM Models', 'desc': 'Which model OpenRouter calls use'},
'tiers': {'icon': '🎚', 'title': 'Tier Limits', 'desc': 'Default result counts per tier'},
} %}
{% for cat_key, entries in categories.items() %}
{% set info = category_info.get(cat_key, {'icon': '⚙', 'title': cat_key.title(), 'desc': ''}) %}
<section class="mb-10">
<div class="mb-4">
<h2 class="text-lg font-semibold text-white flex items-center gap-2">
<span>{{ info.icon }}</span> {{ info.title }}
</h2>
<p class="text-xs text-slate-500 mt-0.5">{{ info.desc }}</p>
</div>
<div class="space-y-2">
{% for entry in entries %}
{% include "partials/config_row.html" %}
{% endfor %}
</div>
</section>
{% endfor %}
<div class="mt-12 p-4 bg-slate-900 border border-slate-800 rounded-xl text-xs text-slate-400">
<p class="font-semibold text-slate-300 mb-1"> How this works</p>
<p>Changes here are stored in the dashboard database. The web-api container polls <code class="text-indigo-300">/api/config</code> every 30 seconds and merges overrides into its in-memory config. No restart is needed — your toggle or model change takes effect within ~30s on the next request.</p>
</div>
{% endblock %}

View file

@ -0,0 +1,153 @@
{% extends "base.html" %}
{% block title %}Cost — didiAI Dashboard{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">Cost Tracking</h1>
<p class="mt-1 text-sm text-slate-400">Estimated spend across search + LLM providers. Real billing lives with each provider.</p>
</div>
<!-- KPI cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<p class="text-xs uppercase tracking-wide text-slate-400">Last 24h</p>
<p class="mt-2 text-3xl font-bold text-white">${{ "%.4f"|format(cost_24h) }}</p>
<p class="mt-1 text-xs text-slate-500">estimated</p>
</div>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<p class="text-xs uppercase tracking-wide text-slate-400">Last 7 days</p>
<p class="mt-2 text-3xl font-bold text-white">${{ "%.2f"|format(cost_7d) }}</p>
<p class="mt-1 text-xs text-slate-500">rolling</p>
</div>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
<p class="text-xs uppercase tracking-wide text-slate-400">Last 30 days</p>
<p class="mt-2 text-3xl font-bold text-white">${{ "%.2f"|format(cost_30d) }}</p>
<p class="mt-1 text-xs text-slate-500">rolling</p>
</div>
<div class="bg-slate-900 rounded-xl border border-indigo-700/40 p-5">
<p class="text-xs uppercase tracking-wide text-indigo-300">Projected monthly</p>
<p class="mt-2 text-3xl font-bold text-white">${{ "%.2f"|format(projected_monthly) }}</p>
<p class="mt-1 text-xs text-slate-500">24h burn × 30</p>
</div>
</div>
<!-- Provider quotas -->
<section class="mb-10">
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-4">Provider Budgets (live)</h2>
<div class="space-y-3">
{% for b in budgets %}
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
<span class="text-white font-medium text-sm">{{ b.name }}</span>
<span class="text-xs text-slate-500">{{ b.kind }}</span>
{% if b.plan_price %}<span class="text-xs text-slate-500">· ${{ "%.0f"|format(b.plan_price) }}/mo</span>{% endif %}
</div>
<span class="text-xs font-mono text-slate-400">
{% if b.unit == 'usd' %}${% endif %}{{ "%.2f"|format(b.used) }} / {% if b.unit == 'usd' %}${% endif %}{{ "%.0f"|format(b.limit) }} ({{ b.percent }}%)
</span>
</div>
{% set color = 'bg-emerald-500' %}
{% if b.percent >= 90 %}{% set color = 'bg-rose-500' %}
{% elif b.percent >= 70 %}{% set color = 'bg-amber-500' %}
{% elif b.percent >= 50 %}{% set color = 'bg-sky-500' %}
{% endif %}
<div class="w-full bg-slate-800 rounded-full h-2 overflow-hidden">
<div class="h-2 {{ color }} transition-all" style="width: {{ b.percent }}%"></div>
</div>
</div>
{% endfor %}
</div>
</section>
<!-- Breakdown tables -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-10">
<section>
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-4">By Provider (30d)</h2>
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<table class="min-w-full divide-y divide-slate-800 text-sm">
<thead class="bg-slate-800/50">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Provider</th>
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Requests</th>
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Cost</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for row in by_provider %}
<tr>
<td class="px-4 py-2 text-white">{{ row.provider }}</td>
<td class="px-4 py-2 text-right text-slate-300">{{ row.count }}</td>
<td class="px-4 py-2 text-right font-mono text-slate-300">${{ "%.4f"|format(row.cost) }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="px-4 py-6 text-center text-slate-500">No data</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
<section>
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-4">By Tier (30d)</h2>
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<table class="min-w-full divide-y divide-slate-800 text-sm">
<thead class="bg-slate-800/50">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Tier</th>
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Requests</th>
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Cost</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for row in by_tier %}
<tr>
<td class="px-4 py-2">
<span class="inline-flex px-2 py-0.5 text-xs rounded-full
{% if row.tier == 'premium' %}bg-fuchsia-900/50 text-fuchsia-300 border border-fuchsia-800
{% else %}bg-sky-900/50 text-sky-300 border border-sky-800{% endif %}">
{{ row.tier }}
</span>
</td>
<td class="px-4 py-2 text-right text-slate-300">{{ row.count }}</td>
<td class="px-4 py-2 text-right font-mono text-slate-300">${{ "%.4f"|format(row.cost) }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="px-4 py-6 text-center text-slate-500">No data</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
</div>
<!-- Top expensive -->
<section class="mb-10">
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-4">Top 10 Most Expensive (30d)</h2>
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<table class="min-w-full divide-y divide-slate-800 text-sm">
<thead class="bg-slate-800/50">
<tr>
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Time</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Tier</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Query</th>
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Cost</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for item in top %}
<tr class="hover:bg-slate-800/30 cursor-pointer" onclick="window.location='/history/{{ item.request_id }}'">
<td class="px-4 py-2 text-slate-400 font-mono text-xs whitespace-nowrap">{{ item.created_at.strftime('%m-%d %H:%M') if item.created_at else '—' }}</td>
<td class="px-4 py-2 text-slate-400">{{ item.tier }}</td>
<td class="px-4 py-2 text-slate-300 max-w-md truncate">{{ item.claim or item.query or '—' }}</td>
<td class="px-4 py-2 text-right font-mono text-slate-300">${{ "%.4f"|format(item.cost_usd|float) }}</td>
</tr>
{% else %}
<tr><td colspan="4" class="px-4 py-6 text-center text-slate-500">No data</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}

Some files were not shown because too many files have changed in this diff Show more