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

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

View file

@ -0,0 +1,108 @@
{% extends "base.html" %}
{% block title %}History — didiAI Dashboard{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">Request History</h1>
<p class="mt-1 text-sm text-slate-400">Last {{ filters.hours }} hours — {{ items|length }} results</p>
</div>
<!-- Filters -->
<form method="get" class="bg-slate-900 rounded-xl border border-slate-800 p-4 mb-6">
<div class="grid grid-cols-2 md:grid-cols-5 gap-3 items-end">
<div>
<label class="block text-xs text-slate-400 mb-1">Tier</label>
<select name="tier" class="w-full bg-slate-800 text-white text-sm rounded-md border border-slate-700 px-3 py-2">
<option value="">All</option>
<option value="free" {% if filters.tier == 'free' %}selected{% endif %}>Free</option>
<option value="premium" {% if filters.tier == 'premium' %}selected{% endif %}>Premium</option>
</select>
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Provider</label>
<input type="text" name="provider" value="{{ filters.provider }}" placeholder="any"
class="w-full bg-slate-800 text-white text-sm rounded-md border border-slate-700 px-3 py-2">
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Endpoint</label>
<input type="text" name="endpoint" value="{{ filters.endpoint }}" placeholder="/v1/gather"
class="w-full bg-slate-800 text-white text-sm rounded-md border border-slate-700 px-3 py-2">
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">Window (hours)</label>
<select name="hours" class="w-full bg-slate-800 text-white text-sm rounded-md border border-slate-700 px-3 py-2">
{% for h in [1, 6, 24, 72, 168, 720] %}
<option value="{{ h }}" {% if filters.hours == h %}selected{% endif %}>{{ h }}h</option>
{% endfor %}
</select>
</div>
<button type="submit" class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-sm rounded-md transition">
Apply
</button>
</div>
</form>
<!-- Table -->
<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">Tier</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Endpoint</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Provider</th>
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Query</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-slate-300 uppercase">Results</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-slate-300 uppercase">Duration</th>
<th class="px-4 py-3 text-right text-xs font-semibold text-slate-300 uppercase">Cost</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-slate-300 uppercase">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800 text-sm">
{% for item in items %}
<tr class="hover:bg-slate-800/30 cursor-pointer" onclick="window.location='/history/{{ item.request_id }}'">
<td class="px-4 py-3 text-slate-400 font-mono text-xs whitespace-nowrap">
{{ item.created_at.strftime('%m-%d %H:%M:%S') if item.created_at else '—' }}
</td>
<td class="px-4 py-3">
<span class="inline-flex px-2 py-0.5 text-xs rounded-full
{% if item.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 %}">
{{ item.tier }}
</span>
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-300">{{ item.endpoint }}</td>
<td class="px-4 py-3 text-slate-400">{{ item.provider or '—' }}</td>
<td class="px-4 py-3 text-slate-300 max-w-xs truncate">{{ item.claim or item.query or '—' }}</td>
<td class="px-4 py-3 text-right text-slate-300">{{ item.results_count if item.results_count is not none else '—' }}</td>
<td class="px-4 py-3 text-right font-mono text-xs
{% if item.duration_ms and item.duration_ms > 30000 %}text-amber-400
{% elif item.duration_ms and item.duration_ms > 60000 %}text-rose-400
{% else %}text-slate-300{% endif %}">
{{ '%.1fs'|format(item.duration_ms/1000) if item.duration_ms else '—' }}
</td>
<td class="px-4 py-3 text-right font-mono text-xs text-slate-400">
{% if item.cost_usd %}${{ '%.4f'|format(item.cost_usd|float) }}{% else %}—{% endif %}
</td>
<td class="px-4 py-3 text-center">
{% if item.status_code and item.status_code < 400 %}
<span class="text-emerald-400"></span>
{% elif item.status_code %}
<span class="text-rose-400" title="HTTP {{ item.status_code }}"></span>
{% else %}
<span class="text-slate-500">?</span>
{% endif %}
</td>
</tr>
{% else %}
<tr>
<td colspan="9" class="px-4 py-12 text-center text-slate-500">No requests in the selected window.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,125 @@
{% extends "base.html" %}
{% block title %}Request {{ item.request_id[:8] }} — didiAI Dashboard{% endblock %}
{% block content %}
<div class="mb-6 flex items-center justify-between">
<a href="/history" class="text-sm text-slate-400 hover:text-white">← Back to history</a>
{% if item.endpoint == '/v1/gather' and item.raw_response %}
<button
hx-post="/api/archive/promote/{{ item.request_id }}"
hx-headers='{"Authorization": "Bearer dashboard-ui"}'
hx-swap="outerHTML"
hx-confirm="Promote this gather to the permanent archive?"
class="text-xs px-3 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-md transition">
📥 Promote to archive
</button>
{% endif %}
</div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-white font-mono">{{ item.request_id }}</h1>
<p class="mt-1 text-sm text-slate-400">
{{ item.created_at.strftime('%Y-%m-%d %H:%M:%S UTC') if item.created_at else '—' }}
</p>
</div>
<!-- Summary -->
<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-4">
<p class="text-xs uppercase text-slate-400">Tier</p>
<p class="mt-1 text-lg font-semibold
{% if item.tier == 'premium' %}text-fuchsia-400{% else %}text-sky-400{% endif %}">
{{ item.tier }}
</p>
</div>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<p class="text-xs uppercase text-slate-400">Endpoint</p>
<p class="mt-1 text-sm font-mono text-white">{{ item.endpoint }}</p>
</div>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<p class="text-xs uppercase text-slate-400">Duration</p>
<p class="mt-1 text-lg font-semibold text-white">
{{ '%.1fs'|format(item.duration_ms/1000) if item.duration_ms else '—' }}
</p>
</div>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
<p class="text-xs uppercase text-slate-400">Status</p>
<p class="mt-1 text-lg font-semibold
{% if item.status_code and item.status_code < 400 %}text-emerald-400
{% elif item.status_code %}text-rose-400{% else %}text-slate-400{% endif %}">
{{ item.status_code or '—' }}
</p>
</div>
</div>
<!-- Claim/query -->
{% if item.claim or item.query %}
<section class="mb-6">
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Query / Claim</h2>
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-slate-200">
{{ item.claim or item.query }}
</div>
</section>
{% endif %}
<!-- Stages (gather only) -->
{% if item.stages %}
<section class="mb-6">
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Pipeline Stages</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">Stage</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Success</th>
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Items</th>
<th class="px-4 py-2 text-right text-xs font-semibold text-slate-300 uppercase">Duration</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800">
{% for stage in item.stages %}
<tr>
<td class="px-4 py-2 text-white font-medium">{{ stage.stage }}</td>
<td class="px-4 py-2">
{% if stage.success %}
<span class="text-emerald-400"></span>
{% else %}
<span class="text-rose-400">✗ {{ stage.error or '' }}</span>
{% endif %}
</td>
<td class="px-4 py-2 text-right text-slate-300">{{ stage.items_processed }}</td>
<td class="px-4 py-2 text-right font-mono text-xs text-slate-300">
{{ '%.0f'|format(stage.duration_ms) }}ms
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
<!-- Raw data -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
{% if item.raw_request %}
<section>
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Request</h2>
<pre class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-xs text-slate-300 overflow-x-auto scrollbar-thin">{{ item.raw_request | tojson(indent=2) }}</pre>
</section>
{% endif %}
{% if item.raw_response %}
<section>
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Response</h2>
<pre class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-xs text-slate-300 overflow-x-auto scrollbar-thin max-h-96">{{ item.raw_response | tojson(indent=2) }}</pre>
</section>
{% endif %}
</div>
{% if item.error %}
<section class="mt-6">
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Error</h2>
<div class="bg-rose-900/20 border border-rose-800 rounded-xl p-4 text-rose-300 text-sm">{{ item.error }}</div>
</section>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Overview — didiAI Dashboard{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-2xl font-bold text-white">Overview</h1>
<p class="mt-1 text-sm text-slate-400">Live status of search providers, LLMs, and recent activity (last 24h)</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">Requests (24h)</p>
<p class="mt-2 text-3xl font-bold text-white">{{ summary.total }}</p>
<p class="mt-1 text-xs text-slate-500">
Free: {{ summary.by_tier.get('free', 0) }} · Premium: {{ summary.by_tier.get('premium', 0) }}
</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">Avg Response</p>
<p class="mt-2 text-3xl font-bold text-white">{{ (summary.avg_duration_ms/1000)|round(1) }}<span class="text-lg text-slate-400">s</span></p>
<p class="mt-1 text-xs text-slate-500">{{ summary.avg_duration_ms|int }}ms mean</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">Error Rate</p>
<p class="mt-2 text-3xl font-bold
{% if summary.error_rate >= 5 %}text-rose-400{% elif summary.error_rate >= 1 %}text-amber-400{% else %}text-emerald-400{% endif %}">
{{ summary.error_rate }}%
</p>
<p class="mt-1 text-xs text-slate-500">{{ summary.errors }} errors total</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">Cost (24h)</p>
<p class="mt-2 text-3xl font-bold text-white">${{ "%.2f"|format(summary.total_cost) }}</p>
<p class="mt-1 text-xs text-slate-500">estimated</p>
</div>
</div>
<!-- Provider grid -->
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">Providers</h2>
<button hx-get="/providers" hx-target="body" class="text-xs text-slate-400 hover:text-white">View details →</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
{% for provider in providers %}
{% include "partials/provider_card.html" %}
{% endfor %}
</div>
{% endblock %}

View file

@ -0,0 +1,79 @@
{% set key = entry.key %}
{% set meta = entry.meta %}
{% set value = entry.value %}
{% set is_override = entry.is_override %}
<div id="cfg-{{ key|replace('.','-') }}" class="bg-slate-900 rounded-xl border {% if is_override %}border-indigo-700/60{% else %}border-slate-800{% endif %} p-4 flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h4 class="font-semibold text-white text-sm">{{ meta.label }}</h4>
{% if is_override %}
<span class="inline-flex px-2 py-0.5 text-[10px] rounded-full bg-indigo-900/50 text-indigo-300 border border-indigo-800">override</span>
{% endif %}
</div>
<p class="text-xs text-slate-400 mt-0.5">{{ meta.description }}</p>
<p class="text-[10px] text-slate-500 mt-1 font-mono">{{ key }}</p>
{% if entry.updated_at %}
<p class="text-[10px] text-slate-600 mt-0.5">Changed by {{ entry.updated_by or 'unknown' }}</p>
{% endif %}
</div>
<div class="flex items-center gap-2 shrink-0">
{% if meta.type == 'bool' %}
<form hx-post="/config/{{ key }}"
hx-target="#cfg-{{ key|replace('.','-') }}"
hx-swap="outerHTML"
class="flex items-center">
<input type="hidden" name="value" value="{{ 'false' if value else 'true' }}">
<button type="submit" role="switch" aria-checked="{{ 'true' if value else 'false' }}"
class="relative inline-flex h-6 w-11 items-center rounded-full transition
{% if value %}bg-emerald-500{% else %}bg-slate-700{% endif %}">
<span class="inline-block h-4 w-4 transform rounded-full bg-white transition
{% if value %}translate-x-6{% else %}translate-x-1{% endif %}"></span>
</button>
</form>
{% elif meta.type == 'enum' %}
<form hx-post="/config/{{ key }}"
hx-target="#cfg-{{ key|replace('.','-') }}"
hx-swap="outerHTML"
class="flex items-center gap-2">
<select name="value" onchange="this.form.requestSubmit()"
class="bg-slate-800 text-white text-xs rounded-md border border-slate-700 px-2 py-1.5">
{% for opt in meta.options %}
<option value="{{ opt }}" {% if opt == value %}selected{% endif %}>{{ opt }}</option>
{% endfor %}
</select>
</form>
{% elif meta.type == 'int' %}
<form hx-post="/config/{{ key }}"
hx-target="#cfg-{{ key|replace('.','-') }}"
hx-swap="outerHTML"
class="flex items-center gap-2">
<input type="number" name="value" value="{{ value }}"
min="{{ meta.get('min', 0) }}" max="{{ meta.get('max', 1000) }}"
class="w-20 bg-slate-800 text-white text-xs rounded-md border border-slate-700 px-2 py-1.5 text-right">
<button type="submit" class="text-xs px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-md">save</button>
</form>
{% elif meta.type == 'string' or meta.type == 'csv' %}
<form hx-post="/config/{{ key }}"
hx-target="#cfg-{{ key|replace('.','-') }}"
hx-swap="outerHTML"
class="flex items-center gap-2">
<input type="text" name="value" value="{{ value }}"
class="w-64 bg-slate-800 text-white text-xs rounded-md border border-slate-700 px-2 py-1.5 font-mono">
<button type="submit" class="text-xs px-3 py-1.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-md">save</button>
</form>
{% endif %}
{% if is_override %}
<form hx-post="/config/{{ key }}/reset"
hx-target="#cfg-{{ key|replace('.','-') }}"
hx-swap="outerHTML">
<button type="submit" class="text-xs text-slate-400 hover:text-rose-300 px-2"
title="Reset to default: {{ meta.default }}">↺</button>
</form>
{% endif %}
</div>
</div>

View file

@ -0,0 +1,50 @@
{% set kind_colors = {
'search': 'from-sky-500 to-indigo-600',
'llm': 'from-fuchsia-500 to-purple-600',
'internal': 'from-emerald-500 to-teal-600'
} %}
<div class="bg-slate-900 rounded-xl border border-slate-800 p-5 hover:border-slate-700 transition">
<div class="flex items-start justify-between mb-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-gradient-to-br {{ kind_colors.get(provider.kind, 'from-slate-500 to-slate-700') }} flex items-center justify-center text-white font-bold text-sm">
{{ provider.display_name[:2]|upper }}
</div>
<div>
<h3 class="text-sm font-semibold text-white">{{ provider.display_name }}</h3>
<p class="text-xs text-slate-400">{{ provider.kind }}{% if provider.plan_name %} · {{ provider.plan_name }}{% endif %}</p>
</div>
</div>
{% if provider.healthy %}
<span class="inline-flex items-center gap-1 text-xs text-emerald-400">
<span class="w-1.5 h-1.5 rounded-full bg-emerald-400"></span> Healthy
</span>
{% else %}
<span class="inline-flex items-center gap-1 text-xs text-rose-400">
<span class="w-1.5 h-1.5 rounded-full bg-rose-400"></span> Down
</span>
{% endif %}
</div>
{% if provider.quota_limit %}
<div class="space-y-1.5 mb-3">
<div class="flex items-baseline justify-between text-xs">
<span class="text-slate-400">
{% if provider.quota_unit == 'usd' %}${% endif %}{{ "%.2f"|format(provider.quota_used or 0) }}
/
{% if provider.quota_unit == 'usd' %}${% endif %}{{ "%.0f"|format(provider.quota_limit) }}
{% if provider.quota_unit != 'usd' %}{{ provider.quota_unit }}{% endif %}
</span>
<span class="font-mono text-slate-300">{{ provider.quota_percent_used or 0 }}%</span>
</div>
{% include "partials/quota_bar.html" %}
</div>
{% elif provider.kind == 'internal' or provider.kind == 'llm' %}
<div class="text-xs text-slate-500 mb-3">{{ provider.plan_name or 'Self-hosted' }}</div>
{% endif %}
<div class="flex items-center gap-3 text-xs text-slate-500 pt-2 border-t border-slate-800">
{% if provider.rate_limit %}<span>⚡ {{ provider.rate_limit }}</span>{% endif %}
{% if provider.plan_price_monthly %}<span>💰 ${{ "%.0f"|format(provider.plan_price_monthly) }}/mo</span>{% endif %}
{% if provider.message %}<span class="text-amber-400">⚠ {{ provider.message }}</span>{% endif %}
</div>
</div>

View file

@ -0,0 +1,9 @@
{% set percent = provider.quota_percent_used or 0 %}
{% set color = 'bg-emerald-500' %}
{% if percent >= 90 %}{% set color = 'bg-rose-500' %}
{% elif percent >= 70 %}{% set color = 'bg-amber-500' %}
{% elif 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: {{ percent }}%"></div>
</div>

View file

@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}Providers — didiAI Dashboard{% endblock %}
{% block content %}
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-white">Providers</h1>
<p class="mt-1 text-sm text-slate-400">Live quota, billing, and health status — refreshed from each provider's API</p>
</div>
<button onclick="location.reload()" class="px-4 py-2 text-sm bg-slate-800 hover:bg-slate-700 text-white rounded-lg border border-slate-700 transition">
↻ Refresh
</button>
</div>
<!-- Group by kind -->
{% set search_providers = providers | selectattr("kind", "equalto", "search") | list %}
{% set llm_providers = providers | selectattr("kind", "equalto", "llm") | list %}
{% set internal_providers = providers | selectattr("kind", "equalto", "internal") | list %}
{% if search_providers %}
<section class="mb-10">
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-4">🔍 Search Providers</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for provider in search_providers %}
{% include "partials/provider_card.html" %}
{% endfor %}
</div>
</section>
{% endif %}
{% if llm_providers %}
<section class="mb-10">
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-4">🧠 LLM Providers</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for provider in llm_providers %}
{% include "partials/provider_card.html" %}
{% endfor %}
</div>
</section>
{% endif %}
{% if internal_providers %}
<section class="mb-10">
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-4">🏠 Internal Services</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for provider in internal_providers %}
{% include "partials/provider_card.html" %}
{% endfor %}
</div>
</section>
{% endif %}
<!-- Extras table -->
<section class="mt-8">
<h2 class="text-sm font-semibold text-slate-400 uppercase tracking-wide mb-4">Raw Details</h2>
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
<table class="min-w-full divide-y divide-slate-800">
<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-left text-xs font-semibold text-slate-300 uppercase">Plan</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Quota</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Rate Limit</th>
<th class="px-4 py-2 text-left text-xs font-semibold text-slate-300 uppercase">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800 text-sm">
{% for provider in providers %}
<tr class="hover:bg-slate-800/30">
<td class="px-4 py-3 font-medium text-white">{{ provider.display_name }}</td>
<td class="px-4 py-3 text-slate-400">{{ provider.plan_name or '—' }}</td>
<td class="px-4 py-3 font-mono text-xs text-slate-300">
{% if provider.quota_limit %}
{% if provider.quota_unit == 'usd' %}${% endif %}{{ "%.2f"|format(provider.quota_used or 0) }} / {% if provider.quota_unit == 'usd' %}${% endif %}{{ "%.0f"|format(provider.quota_limit) }}
{% else %}—{% endif %}
</td>
<td class="px-4 py-3 text-slate-400 text-xs">{{ provider.rate_limit or '—' }}</td>
<td class="px-4 py-3">
{% if provider.healthy %}
<span class="inline-flex px-2 py-0.5 text-xs rounded-full bg-emerald-900/50 text-emerald-300 border border-emerald-800">healthy</span>
{% else %}
<span class="inline-flex px-2 py-0.5 text-xs rounded-full bg-rose-900/50 text-rose-300 border border-rose-800">down</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endblock %}

View file

@ -0,0 +1,96 @@
"""CRUD tests for the DB-backed model/extractor catalog (Val 2).
Uses an in-memory SQLite async DB no Postgres needed. Route handlers are
called directly with an injected session + principal.
"""
from __future__ import annotations
import os
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from dashboard.api.routes import catalog
from dashboard.db.models import Base
# Prefer a real Postgres (matches production) when TEST_DATABASE_URL is set;
# fall back to in-memory SQLite for quick local runs.
_DB_URL = os.environ.get("TEST_DATABASE_URL", "sqlite+aiosqlite:///:memory:")
# HTTPException is imported lazily so the module imports even without fastapi
from fastapi import HTTPException # noqa: E402
@pytest_asyncio.fixture
async def session():
engine = create_async_engine(_DB_URL)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
maker = async_sessionmaker(engine, expire_on_commit=False)
async with maker() as s:
yield s
await engine.dispose()
async def test_crud_lifecycle(session):
created = await catalog.create_catalog(
catalog.CatalogIn(
name="qwen3.5", service="llm", supports_gpu=True, context_length=32768,
quantization="awq",
),
principal="tester",
session=session,
)
assert created["name"] == "qwen3.5"
assert created["supports_gpu"] is True
eid = created["id"]
listed = await catalog.list_catalog(session=session)
assert listed["total"] == 1
filtered = await catalog.list_catalog(service="embeddings", session=session)
assert filtered["total"] == 0
updated = await catalog.update_catalog(
eid, catalog.CatalogPatch(enabled=False, notes="retired"),
principal="tester", session=session,
)
assert updated["enabled"] is False
assert updated["notes"] == "retired"
deleted = await catalog.delete_catalog(eid, principal="tester", session=session)
assert deleted["deleted"] == eid
assert (await catalog.list_catalog(session=session))["total"] == 0
async def test_duplicate_rejected(session):
await catalog.create_catalog(
catalog.CatalogIn(name="bge-m3", service="embeddings"),
principal="t", session=session,
)
with pytest.raises(HTTPException) as exc:
await catalog.create_catalog(
catalog.CatalogIn(name="bge-m3", service="embeddings"),
principal="t", session=session,
)
assert exc.value.status_code == 409
async def test_invalid_kind_rejected(session):
with pytest.raises(HTTPException) as exc:
await catalog.create_catalog(
catalog.CatalogIn(name="x", service="llm", kind="bogus"),
principal="t", session=session,
)
assert exc.value.status_code == 422
async def test_update_missing_404(session):
with pytest.raises(HTTPException) as exc:
await catalog.update_catalog(
999, catalog.CatalogPatch(enabled=True), principal="t", session=session
)
assert exc.value.status_code == 404

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