Livrare LOT 1 - Didi
This commit is contained in:
commit
5380c3fc63
990 changed files with 133308 additions and 0 deletions
40
ai_platform/modules/audio/.env.example
Normal file
40
ai_platform/modules/audio/.env.example
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# audio configuration
|
||||
# Copy to deploy/.env and fill ALL required values.
|
||||
# Service must FAIL to start if required vars are missing.
|
||||
# =============================================================================
|
||||
# REQUIRED (no defaults)
|
||||
# =============================================================================
|
||||
# Whisper model name (tiny, base, small, medium, large-v2, large-v3, large-v3-turbo)
|
||||
AUDIO_MODEL=large-v3-turbo
|
||||
|
||||
# Device: cuda or cpu
|
||||
AUDIO_DEVICE=cuda
|
||||
|
||||
# Model cache directory (shared with other modules)
|
||||
AUDIO_CACHE_DIR=/cai2_ds_storage/hf_cache
|
||||
|
||||
# =============================================================================
|
||||
# Optional Configuration
|
||||
# =============================================================================
|
||||
# Compute type: float16, int8, int8_float16 (int8 recommended for GPU)
|
||||
AUDIO_COMPUTE_TYPE=int8
|
||||
|
||||
# Transcription settings
|
||||
# AUDIO_BEAM_SIZE=5
|
||||
# AUDIO_BEST_OF=5
|
||||
# AUDIO_TEMPERATURE=0.0
|
||||
|
||||
# Server settings
|
||||
# AUDIO_HOST=0.0.0.0
|
||||
# AUDIO_PORT=8200
|
||||
# AUDIO_LOG_LEVEL=INFO
|
||||
|
||||
# Upload limits (in MB)
|
||||
# AUDIO_MAX_FILE_SIZE_MB=500
|
||||
|
||||
# =============================================================================
|
||||
# Optional Nginx Timeouts (for api-nginx profile)
|
||||
# =============================================================================
|
||||
# NGINX_CONNECT_TIMEOUT=60s
|
||||
# NGINX_SEND_TIMEOUT=300s
|
||||
# NGINX_READ_TIMEOUT=600s
|
||||
453
ai_platform/modules/audio/API.md
Normal file
453
ai_platform/modules/audio/API.md
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
# Audio Transcription API Documentation
|
||||
|
||||
OpenAI-compatible speech-to-text transcription API using faster-whisper.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
{BASE_URL}
|
||||
```
|
||||
|
||||
- **Local development:** `http://localhost:8200`
|
||||
- **Docker (internal):** `http://audio-api:8200`
|
||||
- **Direct:** `http://localhost:54300`
|
||||
- **Production:** Use your configured hostname
|
||||
|
||||
## Authentication
|
||||
|
||||
No authentication required by default. Can be added via nginx or API gateway if needed.
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Health Check
|
||||
|
||||
Check API health status.
|
||||
|
||||
**Endpoint:** `GET /health`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl http://localhost:8200/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### List Models
|
||||
|
||||
List available Whisper models (OpenAI-compatible).
|
||||
|
||||
**Endpoint:** `GET /v1/models`
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": "large-v3-turbo",
|
||||
"object": "model",
|
||||
"created": 1700000000,
|
||||
"owned_by": "openai"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
curl http://localhost:8200/v1/models
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Create Transcription
|
||||
|
||||
Transcribe audio file to text (OpenAI-compatible endpoint).
|
||||
|
||||
**Endpoint:** `POST /v1/audio/transcriptions`
|
||||
|
||||
**Content-Type:** `multipart/form-data`
|
||||
|
||||
**Request Parameters:**
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|-----------|------|----------|---------|-------------|
|
||||
| `file` | file | Yes | - | Audio file to transcribe (MP3, WAV, M4A, etc.) |
|
||||
| `model` | string | No | `large-v3-turbo` | Model to use (currently ignored, uses configured model) |
|
||||
| `language` | string | No | `null` | Language code (ISO-639-1). Auto-detected if not specified. |
|
||||
| `prompt` | string | No | `null` | Optional text to guide the model's style |
|
||||
| `response_format` | string | No | `json` | Format: `json`, `text`, or `verbose_json` |
|
||||
| `temperature` | float | No | `0.0` | Sampling temperature (0.0-1.0). Use 0.0 for deterministic output. |
|
||||
|
||||
**Supported Languages (ISO-639-1 codes):**
|
||||
`en`, `es`, `fr`, `de`, `it`, `pt`, `nl`, `pl`, `tr`, `ru`, `ja`, `ko`, `zh`, `ar`, `hi`, and 90+ more languages.
|
||||
|
||||
**Response Formats:**
|
||||
|
||||
#### 1. JSON (default)
|
||||
```json
|
||||
{
|
||||
"text": "Full transcription text",
|
||||
"language": "en",
|
||||
"duration": 45.5
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Text
|
||||
```
|
||||
Full transcription text
|
||||
```
|
||||
|
||||
#### 3. Verbose JSON
|
||||
```json
|
||||
{
|
||||
"text": "Full transcription text",
|
||||
"language": "en",
|
||||
"duration": 45.5,
|
||||
"segments": [
|
||||
{
|
||||
"id": 0,
|
||||
"seek": 0,
|
||||
"start": 0.0,
|
||||
"end": 3.5,
|
||||
"text": "Hello, world!",
|
||||
"tokens": [15496, 11, 1002, 0],
|
||||
"temperature": 0.0,
|
||||
"avg_logprob": -0.25,
|
||||
"compression_ratio": 1.5,
|
||||
"no_speech_prob": 0.01
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
**Basic transcription (JSON):**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||
-F "file=@audio.mp3" \
|
||||
-F "response_format=json"
|
||||
```
|
||||
|
||||
**With language specification:**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||
-F "file=@audio.mp3" \
|
||||
-F "language=en" \
|
||||
-F "response_format=json"
|
||||
```
|
||||
|
||||
**Text format:**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||
-F "file=@audio.mp3" \
|
||||
-F "response_format=text"
|
||||
```
|
||||
|
||||
**Verbose JSON with segments:**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||
-F "file=@audio.mp3" \
|
||||
-F "response_format=verbose_json"
|
||||
```
|
||||
|
||||
**With initial prompt (to guide style):**
|
||||
```bash
|
||||
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||
-F "file=@audio.mp3" \
|
||||
-F "prompt=This is a technical discussion about machine learning." \
|
||||
-F "response_format=json"
|
||||
```
|
||||
|
||||
**Python example:**
|
||||
```python
|
||||
import requests
|
||||
|
||||
url = "http://localhost:8200/v1/audio/transcriptions"
|
||||
|
||||
with open("audio.mp3", "rb") as f:
|
||||
files = {"file": f}
|
||||
data = {
|
||||
"model": "large-v3-turbo",
|
||||
"language": "en",
|
||||
"response_format": "verbose_json"
|
||||
}
|
||||
|
||||
response = requests.post(url, files=files, data=data)
|
||||
result = response.json()
|
||||
|
||||
print(f"Transcription: {result['text']}")
|
||||
print(f"Duration: {result['duration']}s")
|
||||
print(f"Segments: {len(result['segments'])}")
|
||||
```
|
||||
|
||||
**JavaScript example:**
|
||||
```javascript
|
||||
const formData = new FormData();
|
||||
formData.append('file', audioFile);
|
||||
formData.append('model', 'large-v3-turbo');
|
||||
formData.append('response_format', 'json');
|
||||
|
||||
const response = await fetch('http://localhost:8200/v1/audio/transcriptions', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
console.log('Transcription:', result.text);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
### Standard Error Format
|
||||
|
||||
```json
|
||||
{
|
||||
"detail": "Error message"
|
||||
}
|
||||
```
|
||||
|
||||
### Common HTTP Status Codes
|
||||
|
||||
| Code | Meaning | Description |
|
||||
|------|---------|-------------|
|
||||
| 200 | OK | Request successful |
|
||||
| 400 | Bad Request | Invalid request parameters |
|
||||
| 413 | Payload Too Large | File exceeds max size limit (default 500MB) |
|
||||
| 422 | Unprocessable Entity | Invalid file format or corrupted audio |
|
||||
| 500 | Internal Server Error | Transcription failed |
|
||||
| 503 | Service Unavailable | Model not loaded or GPU unavailable |
|
||||
|
||||
**Example error response:**
|
||||
```json
|
||||
{
|
||||
"detail": "File too large: 550.0MB (max: 500MB)"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
No built-in rate limiting. Can be added via nginx or API gateway.
|
||||
|
||||
**Recommended nginx configuration:**
|
||||
```nginx
|
||||
limit_req_zone $binary_remote_addr zone=audio_limit:10m rate=10r/m;
|
||||
|
||||
location / {
|
||||
limit_req zone=audio_limit burst=5;
|
||||
proxy_pass http://audio-api:8200;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Size Limits
|
||||
|
||||
- **Default:** 500MB per file
|
||||
- **Configurable via:** `AUDIO_MAX_FILE_SIZE_MB` environment variable
|
||||
- **Recommended:** Keep files under 100MB for best performance
|
||||
|
||||
---
|
||||
|
||||
## Supported Audio Formats
|
||||
|
||||
All formats supported by FFmpeg, including:
|
||||
|
||||
**Audio files:**
|
||||
- MP3, WAV, FLAC, OGG, M4A, AAC, WMA, OPUS
|
||||
|
||||
**Video files (audio track extraction):**
|
||||
- MP4, AVI, MKV, MOV, WEBM, FLV
|
||||
|
||||
**Professional formats:**
|
||||
- PCM, AIFF, AU, AMR
|
||||
|
||||
---
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Processing Time
|
||||
|
||||
Approximate transcription times for large-v3-turbo with int8 on H200 GPU:
|
||||
|
||||
| Audio Length | Processing Time | Real-Time Factor |
|
||||
|--------------|-----------------|------------------|
|
||||
| 1 minute | ~3-5 seconds | 0.05-0.08x |
|
||||
| 10 minutes | ~30-50 seconds | 0.05-0.08x |
|
||||
| 1 hour | ~3-5 minutes | 0.05-0.08x |
|
||||
|
||||
**Real-Time Factor (RTF):** Processing time / Audio duration. Lower is better.
|
||||
- RTF < 0.1x = Excellent (8x faster than real-time)
|
||||
- RTF < 0.5x = Good (2x faster than real-time)
|
||||
- RTF = 1.0x = Real-time
|
||||
|
||||
### Optimization Tips
|
||||
|
||||
1. **Use language parameter** when known (skip auto-detection)
|
||||
2. **Use appropriate model size** based on accuracy needs
|
||||
3. **Process shorter segments** for long recordings (split at silence)
|
||||
4. **Monitor GPU utilization** with `nvidia-smi`
|
||||
|
||||
---
|
||||
|
||||
## Comparison with OpenAI API
|
||||
|
||||
This API is **fully compatible** with OpenAI's transcription endpoint:
|
||||
|
||||
| Feature | OpenAI | This API |
|
||||
|---------|--------|----------|
|
||||
| Endpoint | `/v1/audio/transcriptions` | `/v1/audio/transcriptions` ✅ |
|
||||
| Request format | `multipart/form-data` | `multipart/form-data` ✅ |
|
||||
| Response formats | `json`, `text`, `verbose_json` | `json`, `text`, `verbose_json` ✅ |
|
||||
| Language detection | Auto | Auto ✅ |
|
||||
| Max file size | 25MB | 500MB (configurable) ✅ |
|
||||
| Cost | $0.006/minute | Free (self-hosted) ✅ |
|
||||
| Latency | Variable | ~0.05-0.08x RTF ✅ |
|
||||
|
||||
**Drop-in replacement:** Change only the base URL to switch from OpenAI to this API.
|
||||
|
||||
---
|
||||
|
||||
## SDK Examples
|
||||
|
||||
### OpenAI Python SDK
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
|
||||
# Point to local API
|
||||
client = OpenAI(
|
||||
api_key="not-needed", # No auth required
|
||||
base_url="http://localhost:8200/v1"
|
||||
)
|
||||
|
||||
with open("audio.mp3", "rb") as f:
|
||||
transcript = client.audio.transcriptions.create(
|
||||
model="large-v3-turbo",
|
||||
file=f,
|
||||
response_format="verbose_json"
|
||||
)
|
||||
|
||||
print(transcript.text)
|
||||
```
|
||||
|
||||
### OpenAI Node.js SDK
|
||||
|
||||
```javascript
|
||||
import OpenAI from 'openai';
|
||||
import fs from 'fs';
|
||||
|
||||
const openai = new OpenAI({
|
||||
apiKey: 'not-needed',
|
||||
baseURL: 'http://localhost:8200/v1'
|
||||
});
|
||||
|
||||
const transcription = await openai.audio.transcriptions.create({
|
||||
file: fs.createReadStream('audio.mp3'),
|
||||
model: 'large-v3-turbo',
|
||||
response_format: 'verbose_json'
|
||||
});
|
||||
|
||||
console.log(transcription.text);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Voice Activity Detection (VAD)
|
||||
|
||||
Enabled by default. Automatically skips silence for:
|
||||
- Faster processing
|
||||
- Better accuracy
|
||||
- Smaller output
|
||||
|
||||
### Beam Search
|
||||
|
||||
Configurable via `AUDIO_BEAM_SIZE` (default: 5). Higher values = better accuracy but slower.
|
||||
|
||||
### Temperature Sampling
|
||||
|
||||
- `0.0` (default): Deterministic output
|
||||
- `0.0-1.0`: More creative/diverse outputs (less reliable)
|
||||
|
||||
---
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Health Check
|
||||
|
||||
```bash
|
||||
# Check if API is ready
|
||||
curl http://localhost:8200/health
|
||||
|
||||
# Expected response
|
||||
{"status": "ok"}
|
||||
```
|
||||
|
||||
### Logs
|
||||
|
||||
```bash
|
||||
# View real-time logs
|
||||
cd deploy/
|
||||
docker compose logs -f audio-api
|
||||
|
||||
# Check for errors
|
||||
docker compose logs audio-api | grep ERROR
|
||||
```
|
||||
|
||||
### GPU Usage
|
||||
|
||||
```bash
|
||||
# Monitor GPU while transcribing
|
||||
watch -n 1 nvidia-smi
|
||||
|
||||
# Check VRAM usage
|
||||
nvidia-smi --query-gpu=memory.used,memory.total --format=csv
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Error: File too large
|
||||
|
||||
**Solution:** Increase max file size or split audio into smaller chunks:
|
||||
```bash
|
||||
# Increase limit
|
||||
AUDIO_MAX_FILE_SIZE_MB=1000
|
||||
|
||||
# Or split with ffmpeg
|
||||
ffmpeg -i long_audio.mp3 -f segment -segment_time 600 -c copy chunk_%03d.mp3
|
||||
```
|
||||
|
||||
### Error: Transcription failed
|
||||
|
||||
**Possible causes:**
|
||||
1. Corrupted audio file - verify with media player
|
||||
2. Unsupported format - convert to MP3/WAV
|
||||
3. GPU out of memory - use smaller model or CPU mode
|
||||
4. Audio is pure noise/music - Whisper is designed for speech
|
||||
|
||||
### Slow performance
|
||||
|
||||
**Check:**
|
||||
1. GPU is being used: `docker exec audio-api nvidia-smi`
|
||||
2. Model is correct: Check logs for model loading messages
|
||||
3. Compute type is int8: Faster than float16
|
||||
|
||||
---
|
||||
|
||||
For more information, see [README.md](README.md) and the [faster-whisper documentation](https://github.com/SYSTRAN/faster-whisper).
|
||||
137
ai_platform/modules/audio/INDEX.md
Normal file
137
ai_platform/modules/audio/INDEX.md
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
# Audio Transcription Service (M17-Whisper)
|
||||
|
||||
Audio transcription service for DIDI media analysis. Whisper-based (M17-Whisper, faster-whisper backend), used by agent-v3 media-preprocess worker for video/audio session pipelines (techniques, ai-tampered, claims). OpenAI-compatible API — drop-in for `client.audio.transcriptions.create()`.
|
||||
|
||||
## Stack
|
||||
- Python 3.10+ / FastAPI / Uvicorn
|
||||
- Backend: `faster-whisper` >= 1.0.0 (CTranslate2 optimized inference)
|
||||
- Default model: `large-v3-turbo` (809M params, ~6GB VRAM int8)
|
||||
- GPU: CUDA (shared GPU 0 with Qwen3.5-35B-A3B)
|
||||
- URL (Dev): `http://10.11.10.17:54300/v1/audio/transcriptions`
|
||||
- Container: `didiAI-audio-api` (GPU host)
|
||||
- Auth: none on the service itself; agent-v3 uses bearer token via `M17_WHISPER_TOKEN` (enforced by gateway/nginx if configured)
|
||||
|
||||
## Ce face
|
||||
Speech-to-text transcription pe fişiere audio sau URL-uri. agent-v3 trimite fie audio buffer (multipart upload), fie URL public (din MinIO), primeşte text + metadata (lang detect, duration, optional segments). Folosit în:
|
||||
- **Video analysis pipeline** — agent-v3 extrage track-ul audio cu ffmpeg, trimite la M17-Whisper, foloseşte transcript pentru `claims` + `techniques` + `ai-tampered`
|
||||
- **Audio-only sessions** — direct upload, transcripted, apoi pipeline normal de analiză text
|
||||
- **Cascade fallback** — agent-v3 `transcription.ts` are cascadă: M17-Whisper (local GPU) → Groq Whisper → OpenAI Whisper. Dacă local pică sau întoarce string gol, trece la următorul provider.
|
||||
|
||||
## API endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| `GET` | `/health` | Health probe (returns `{"status":"ok"}`) |
|
||||
| `GET` | `/v1/models` | List models, OpenAI-compatible |
|
||||
| `POST` | `/v1/audio/transcriptions` | Transcribe audio (OpenAI-compatible) |
|
||||
| `GET` | `/v1/info` | Service catalog metadata (used by didi catalog-api) |
|
||||
|
||||
### `POST /v1/audio/transcriptions`
|
||||
Content-Type: `multipart/form-data`
|
||||
|
||||
| Param | Type | Default | Notes |
|
||||
|-------|------|---------|-------|
|
||||
| `file` | file | — | Audio file (MP3/WAV/M4A/FLAC/OGG/MP4 audio track…). XOR with `url`. |
|
||||
| `url` | string | — | URL to download audio from (DIDI extension over OpenAI). XOR with `file`. |
|
||||
| `model` | string | configured | Ignored — server uses `AUDIO_MODEL` env. |
|
||||
| `language` | string | auto | ISO-639-1 code (`en`, `ro`, `es`, …). Auto-detect if omitted. |
|
||||
| `prompt` | string | none | Optional initial prompt to bias style/vocab. |
|
||||
| `response_format` | string | `json` | `json` \| `text` \| `verbose_json` (segments). |
|
||||
| `temperature` | float | `0.0` | Sampling temperature 0.0–1.0. |
|
||||
|
||||
Response (json): `{ text, language, duration }` — verbose_json adds `segments[]` with `start/end/text/tokens/avg_logprob/no_speech_prob`.
|
||||
|
||||
Errors: `400` invalid params / download failed, `413` payload too large (default 500MB), `422` corrupted audio, `500` transcription failed, `503` model not loaded.
|
||||
|
||||
## Backends / Models
|
||||
- Production: `large-v3-turbo` (8x faster than `large-v3`, similar quality, ~6GB VRAM int8)
|
||||
- Available: `tiny`, `base`, `small`, `medium`, `large-v3`, `large-v3-turbo`
|
||||
- VAD (Voice Activity Detection) enabled by default — skip silence
|
||||
- Quantization: `int8` (recommended), `float16`, `int8_float16`
|
||||
|
||||
## How didi-backend uses it
|
||||
- agent-v3 cascade: `backend/services/orchestration-layer/agent-v3/src/shared/media/transcription.ts`
|
||||
- Order: **M17-Whisper (local GPU)** → Groq Whisper → OpenAI Whisper
|
||||
- Empty-string from local triggers retry on next provider (Faza 1 din SAFETY_NETS_PLAN)
|
||||
- Configured în agent-v3 docker-compose env:
|
||||
- `M17_WHISPER_URL=http://10.11.10.17:54300`
|
||||
- `M17_WHISPER_TOKEN=<gateway-token>` (optional, dacă există proxy auth)
|
||||
- Apelat din **media-preprocess worker** la sesiuni cu `media_type` în `{audio, video}` — transcript devine input pentru `claims-routes.ts`, `routes.ts` (techniques), `ai-tampered-routes.ts`.
|
||||
- Video flow: ffmpeg extract → upload temp / pass buffer → POST `/v1/audio/transcriptions` → text → pipeline analysis.
|
||||
|
||||
## Configuration
|
||||
|
||||
Env vars (prefix `AUDIO_`, set in `deploy/.env`):
|
||||
|
||||
**Required**
|
||||
- `AUDIO_MODEL` — Whisper model (default `large-v3-turbo`)
|
||||
- `AUDIO_DEVICE` — `cuda` | `cpu`
|
||||
- `AUDIO_CACHE_DIR` — HuggingFace model cache path (`/cai2_ds_storage/hf_cache` typical)
|
||||
- `AUDIO_EXTERNAL_URL` — used in OpenAPI servers spec
|
||||
|
||||
**Optional**
|
||||
- `AUDIO_COMPUTE_TYPE` — `int8` (default), `float16`, `int8_float16`
|
||||
- `AUDIO_BEAM_SIZE` — beam search width (default 5)
|
||||
- `AUDIO_BEST_OF` — sampling candidates (default 5)
|
||||
- `AUDIO_TEMPERATURE` — default 0.0
|
||||
- `AUDIO_HOST` / `AUDIO_PORT` — default `0.0.0.0:54300`
|
||||
- `AUDIO_LOG_LEVEL` — default `INFO`
|
||||
- `AUDIO_MAX_FILE_SIZE_MB` — upload cap (default 500)
|
||||
- `CUDA_VISIBLE_DEVICES=0` — pinned to GPU 0
|
||||
|
||||
## Deployment
|
||||
|
||||
Compose dir: `/home/admin365/didi_mono/ai_platform/modules/audio/deploy/`
|
||||
|
||||
```bash
|
||||
cd modules/audio/deploy
|
||||
cp ../.env.example .env # edit values
|
||||
./deploy.sh --profile api --detach # API only
|
||||
./deploy.sh --profile api-nginx --detach # with nginx reverse proxy
|
||||
./deploy.sh --profile api --logs # tail logs
|
||||
docker compose restart audio-api # quick restart
|
||||
```
|
||||
|
||||
- Container name: `didiAI-audio-api`
|
||||
- Image: `didiai-audio-api`
|
||||
- Network: `didi-network` (external, shared with other AI modules)
|
||||
- GPU reservation: NVIDIA driver, device `0`
|
||||
- Healthcheck: HTTP `GET /health` every 30s, 60s start period (model load)
|
||||
- Model cache mount: `${AUDIO_CACHE_DIR}:/root/.cache/huggingface`
|
||||
|
||||
## Performance
|
||||
- Real-Time Factor on H200 GPU + int8 + `large-v3-turbo`: **~0.05–0.08x** (10s audio = 0.5–1s transcription, 1h audio = 3–5min)
|
||||
- VRAM: ~6GB for large-v3-turbo int8, ~8GB float16
|
||||
- Throughput: sequential — process files one at a time per GPU
|
||||
- File size cap: 500MB default (configurable)
|
||||
- Recommended: pre-split audio > 1h cu ffmpeg segments
|
||||
|
||||
## Architecture (source layout)
|
||||
```
|
||||
modules/audio/
|
||||
├── deploy/
|
||||
│ ├── deploy.sh # CLI wrapper
|
||||
│ ├── docker-compose.yml # didiAI-audio-api service
|
||||
│ ├── Dockerfile # CUDA + faster-whisper image
|
||||
│ └── .env # runtime config
|
||||
├── src/audio/
|
||||
│ ├── app.py # FastAPI routes + /v1/info catalog
|
||||
│ ├── transcriber.py # faster-whisper wrapper (singleton)
|
||||
│ ├── schemas.py # TranscriptionResponse / Segment
|
||||
│ └── settings.py # pydantic-settings (AUDIO_* env)
|
||||
├── tests/
|
||||
├── pyproject.toml # fastapi, faster-whisper, httpx
|
||||
├── README.md # full operator guide
|
||||
├── API.md # full HTTP API reference
|
||||
└── INDEX.md # this file
|
||||
```
|
||||
|
||||
## Related
|
||||
- agent-v3 transcription cascade — `backend/services/orchestration-layer/agent-v3/src/shared/media/transcription.ts`
|
||||
- agent-v3 media routes — `routes.ts` / `ai-tampered-routes.ts` / `claims-routes.ts`
|
||||
- media-preprocess worker (in agent-v3) — invokes this service
|
||||
- Test files in MinIO (used during refactor verification):
|
||||
- Audio: `https://didi365.eu/api/v3/media/file/uploads/test-user/1771883851173-audio_with_voice.mp3`
|
||||
- Video: `https://didi365.eu/api/v3/media/file/uploads/test-user/1771883852186-voice_video.mp4`
|
||||
- Faza 1 safety net (transcription retry on empty) — `agent-v3/SAFETY_NETS_PLAN.md`
|
||||
- Upstream refs: [faster-whisper](https://github.com/SYSTRAN/faster-whisper), [OpenAI Whisper](https://github.com/openai/whisper)
|
||||
289
ai_platform/modules/audio/README.md
Normal file
289
ai_platform/modules/audio/README.md
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
# Audio Transcription
|
||||
|
||||
Speech-to-text transcription service using faster-whisper for optimized inference.
|
||||
|
||||
## Features
|
||||
|
||||
- **High-Performance Transcription**: Uses faster-whisper (4x faster than original Whisper)
|
||||
- **Multiple Model Sizes**: Support for tiny, base, small, medium, large-v2, large-v3, and large-v3-turbo
|
||||
- **GPU Acceleration**: Optimized CUDA inference with int8 quantization
|
||||
- **OpenAI-Compatible API**: Drop-in replacement for OpenAI's transcription endpoint
|
||||
- **Multiple Output Formats**: JSON, text, and verbose JSON with segments
|
||||
- **Language Detection**: Automatic language detection for 99+ languages
|
||||
- **VAD Filtering**: Voice Activity Detection for improved accuracy
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Required (all modules):**
|
||||
- All global prerequisites (see main [README.md](../../README.md))
|
||||
- NVIDIA GPU with CUDA support (recommended for production)
|
||||
- ~6GB VRAM for large-v3-turbo with int8 quantization
|
||||
|
||||
**Optional:**
|
||||
- CPU-only mode available (slower but no GPU required)
|
||||
|
||||
> **Note:** The audio module runs on GPU 0 (shared with Qwen3.5-35B-A3B).
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd modules/audio
|
||||
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Install with dev dependencies
|
||||
uv sync --extra dev
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### As an API Server
|
||||
|
||||
```bash
|
||||
cd deploy/
|
||||
|
||||
# Create deployment env file
|
||||
cp ../.env.example .env
|
||||
# Edit deploy/.env with your configuration
|
||||
|
||||
# Start the server
|
||||
./deploy.sh --profile api --detach
|
||||
|
||||
# Or with nginx reverse proxy
|
||||
./deploy.sh --profile api-nginx --detach
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/health` | GET | Health check |
|
||||
| `/v1/models` | GET | List available models |
|
||||
| `/v1/audio/transcriptions` | POST | Transcribe audio (OpenAI-compatible) |
|
||||
|
||||
### Example API Request
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8200/health
|
||||
|
||||
# Transcribe audio file
|
||||
AUDIO="/path/to/audio.mp3"
|
||||
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||
-F "file=@${AUDIO}" \
|
||||
-F "model=large-v3-turbo" \
|
||||
-F "response_format=json"
|
||||
|
||||
# With language specification and verbose output
|
||||
curl -X POST "http://localhost:8200/v1/audio/transcriptions" \
|
||||
-F "file=@${AUDIO}" \
|
||||
-F "language=en" \
|
||||
-F "response_format=verbose_json"
|
||||
```
|
||||
|
||||
### Example Response
|
||||
|
||||
**JSON format:**
|
||||
```json
|
||||
{
|
||||
"text": "This is the full transcription of your audio file.",
|
||||
"language": "en",
|
||||
"duration": 45.5
|
||||
}
|
||||
```
|
||||
|
||||
**Verbose JSON format:**
|
||||
```json
|
||||
{
|
||||
"text": "This is the full transcription.",
|
||||
"language": "en",
|
||||
"duration": 45.5,
|
||||
"segments": [
|
||||
{
|
||||
"id": 0,
|
||||
"start": 0.0,
|
||||
"end": 3.5,
|
||||
"text": "This is the full transcription.",
|
||||
"tokens": [123, 456, 789],
|
||||
"temperature": 0.0,
|
||||
"avg_logprob": -0.25,
|
||||
"compression_ratio": 1.5,
|
||||
"no_speech_prob": 0.01
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
Configured via environment variables (prefix: `AUDIO_`). These are typically set in `deploy/.env`:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AUDIO_MODEL` | Whisper model name (e.g., `large-v3-turbo`) |
|
||||
| `AUDIO_DEVICE` | Device: `cuda` or `cpu` |
|
||||
| `AUDIO_CACHE_DIR` | Model cache directory |
|
||||
|
||||
### Optional Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `AUDIO_COMPUTE_TYPE` | `int8` | Compute type: `float16`, `int8`, `int8_float16` |
|
||||
| `AUDIO_BEAM_SIZE` | `5` | Beam size for decoding (1-10) |
|
||||
| `AUDIO_BEST_OF` | `5` | Number of candidates when sampling |
|
||||
| `AUDIO_TEMPERATURE` | `0.0` | Sampling temperature (0.0-1.0) |
|
||||
| `AUDIO_LOG_LEVEL` | `INFO` | Log level |
|
||||
| `AUDIO_MAX_FILE_SIZE_MB` | `500` | Max upload file size in MB |
|
||||
|
||||
### Available Models
|
||||
|
||||
| Model | Parameters | VRAM (int8) | Speed | Quality |
|
||||
|-------|-----------|-------------|-------|---------|
|
||||
| `tiny` | 39M | ~1GB | 10x | Basic |
|
||||
| `base` | 74M | ~1GB | 7x | Good |
|
||||
| `small` | 244M | ~2GB | 4x | Better |
|
||||
| `medium` | 769M | ~3-4GB | 2x | Very Good |
|
||||
| `large-v3` | 1550M | ~6-8GB | 1x | Excellent |
|
||||
| `large-v3-turbo` | 809M | ~6GB | 8x | Excellent |
|
||||
|
||||
**Recommendation:** Use `large-v3-turbo` for best balance of speed and accuracy.
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
cd deploy/
|
||||
|
||||
# Create deployment env file (REQUIRED)
|
||||
cp ../.env.example .env
|
||||
# Edit deploy/.env with your settings
|
||||
|
||||
# API only
|
||||
./deploy.sh --profile api --detach
|
||||
|
||||
# API with nginx reverse proxy
|
||||
./deploy.sh --profile api-nginx --detach
|
||||
|
||||
# View logs
|
||||
./deploy.sh --profile api --logs
|
||||
|
||||
# Stop services
|
||||
./deploy.sh --profile api --down
|
||||
```
|
||||
|
||||
### Common Docker Commands
|
||||
|
||||
```bash
|
||||
cd deploy/
|
||||
|
||||
# Restart the API service
|
||||
docker compose restart audio-api
|
||||
|
||||
# Rebuild after code changes
|
||||
docker compose build audio-api
|
||||
docker compose up -d audio-api
|
||||
|
||||
# Check logs
|
||||
docker compose logs -f audio-api
|
||||
```
|
||||
|
||||
### Port Allocation
|
||||
|
||||
| Port | Service |
|
||||
|------|---------|
|
||||
| `8200` | Audio API |
|
||||
| `54300` | Audio API (Dev + AI + Audio) |
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dev dependencies
|
||||
uv sync --extra dev
|
||||
|
||||
# Run tests
|
||||
uv run pytest
|
||||
|
||||
# Run tests with coverage
|
||||
uv run pytest --cov=src/audio --cov-report=term-missing
|
||||
|
||||
# Lint and format
|
||||
uv run ruff check .
|
||||
uv run ruff format .
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
modules/audio/
|
||||
├── deploy/
|
||||
│ ├── deploy.sh # Deployment script
|
||||
│ ├── docker-compose.yml # Docker services
|
||||
│ ├── Dockerfile # Container image
|
||||
│ └── nginx.conf # Nginx reverse proxy config (optional)
|
||||
├── src/audio/
|
||||
│ ├── __init__.py
|
||||
│ ├── app.py # FastAPI application
|
||||
│ ├── transcriber.py # faster-whisper wrapper
|
||||
│ ├── schemas.py # Response schemas
|
||||
│ └── settings.py # Configuration
|
||||
├── tests/
|
||||
├── .env.example # Environment template
|
||||
├── API.md # API documentation
|
||||
├── pyproject.toml # Dependencies
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Supported Audio Formats
|
||||
|
||||
faster-whisper (via FFmpeg) supports:
|
||||
- MP3, WAV, FLAC, OGG, M4A, AAC, WMA
|
||||
- MP4, AVI, MKV (audio track extraction)
|
||||
- And many more formats supported by FFmpeg
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use int8 quantization** for GPU inference (40% memory savings, minimal accuracy loss)
|
||||
2. **Use large-v3-turbo** for best speed/accuracy tradeoff (8x faster than large-v3)
|
||||
3. **Enable VAD filtering** (enabled by default) to skip silence
|
||||
4. **Batch processing**: Process multiple files sequentially for better GPU utilization
|
||||
5. **Language specification**: Specify language code when known for faster processing
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Out of Memory
|
||||
|
||||
**Solution:** Use a smaller model or reduce compute type:
|
||||
```bash
|
||||
# Use medium model instead
|
||||
AUDIO_MODEL=medium
|
||||
|
||||
# Or use float16 instead of int8 (uses more VRAM but may work better)
|
||||
AUDIO_COMPUTE_TYPE=float16
|
||||
```
|
||||
|
||||
### Issue: Slow Transcription
|
||||
|
||||
**Solution:**
|
||||
- Ensure GPU is being used (`AUDIO_DEVICE=cuda`)
|
||||
- Use int8 compute type for faster inference
|
||||
- Use large-v3-turbo instead of large-v3
|
||||
- Check GPU utilization with `nvidia-smi`
|
||||
|
||||
### Issue: Model Download Fails
|
||||
|
||||
**Solution:** Check network connectivity and cache directory permissions:
|
||||
```bash
|
||||
# Verify cache directory exists and is writable
|
||||
ls -la /cai2_ds_storage/hf_cache
|
||||
|
||||
# Or change to local directory
|
||||
AUDIO_CACHE_DIR=/home/user/.cache/huggingface
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [faster-whisper GitHub](https://github.com/SYSTRAN/faster-whisper)
|
||||
- [OpenAI Whisper](https://github.com/openai/whisper)
|
||||
- [Whisper Model Card](https://github.com/openai/whisper/blob/main/model-card.md)
|
||||
40
ai_platform/modules/audio/deploy/Dockerfile
Normal file
40
ai_platform/modules/audio/deploy/Dockerfile
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Audio Transcription Service - Dockerfile
|
||||
# Uses faster-whisper for optimized speech-to-text transcription
|
||||
|
||||
FROM nvidia/cuda:12.1.0-runtime-ubuntu22.04
|
||||
|
||||
# Prevent interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV TZ=Europe/Bucharest
|
||||
|
||||
# Install Python 3.10 (default in Ubuntu 22.04) and system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
python3 \
|
||||
python3-pip \
|
||||
python3-dev \
|
||||
curl \
|
||||
ffmpeg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& ln -sf /usr/bin/python3 /usr/bin/python
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy project files
|
||||
COPY pyproject.toml /app/
|
||||
COPY src/ /app/src/
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -e .
|
||||
|
||||
# Create cache directory
|
||||
RUN mkdir -p /root/.cache/huggingface
|
||||
|
||||
# Default port
|
||||
ENV AUDIO_PORT=54300
|
||||
|
||||
# Expose port
|
||||
EXPOSE 54300
|
||||
|
||||
# Run FastAPI with uvicorn
|
||||
CMD python -m uvicorn audio.app:app --host 0.0.0.0 --port ${AUDIO_PORT}
|
||||
116
ai_platform/modules/audio/deploy/deploy.sh
Normal file
116
ai_platform/modules/audio/deploy/deploy.sh
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Docker Compose Startup Script for Audio Transcription
|
||||
#
|
||||
# Usage: ./deploy/deploy.sh [OPTIONS]
|
||||
#
|
||||
# Options:
|
||||
# --profile <api|api-nginx> Docker compose profile
|
||||
# --detach Run in detached mode
|
||||
# --down Stop and remove containers
|
||||
# --logs Show logs
|
||||
# --help Show this help message
|
||||
#
|
||||
# Required: Set environment variables in deploy/.env file or export them before running.
|
||||
# See ../.env.example (module root) for the full list of variables.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Load .env file ONLY from deploy/ directory
|
||||
if [[ -f "$SCRIPT_DIR/.env" ]]; then
|
||||
echo "Loading environment from: $SCRIPT_DIR/.env"
|
||||
set -a
|
||||
source "$SCRIPT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
PROFILE=""
|
||||
DETACH=""
|
||||
ACTION="up"
|
||||
|
||||
show_help() {
|
||||
sed -n '2,18p' "$0" | sed 's/^# //' | sed 's/^#//'
|
||||
exit 0
|
||||
}
|
||||
|
||||
check_required_var() {
|
||||
local var_name="$1"
|
||||
if [[ -z "${!var_name:-}" ]]; then
|
||||
echo "ERROR: Required environment variable $var_name is not set"
|
||||
echo "Set it in deploy/.env file or export it before running this script"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--profile)
|
||||
PROFILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--detach|-d)
|
||||
DETACH="-d"
|
||||
shift
|
||||
;;
|
||||
--down)
|
||||
ACTION="down"
|
||||
shift
|
||||
;;
|
||||
--logs)
|
||||
ACTION="logs"
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Require profile when bringing up
|
||||
if [[ -z "$PROFILE" && "$ACTION" == "up" ]]; then
|
||||
echo "ERROR: --profile is required"
|
||||
echo "Options: api, api-nginx"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fail-fast required vars
|
||||
check_required_var "AUDIO_MODEL"
|
||||
check_required_var "AUDIO_DEVICE"
|
||||
check_required_var "AUDIO_CACHE_DIR"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
case $ACTION in
|
||||
up)
|
||||
echo "Starting Audio Transcription API with profile: $PROFILE"
|
||||
echo " Model: $AUDIO_MODEL"
|
||||
echo " Device: $AUDIO_DEVICE"
|
||||
echo " Compute type: ${AUDIO_COMPUTE_TYPE:-int8}"
|
||||
echo " Cache dir: $AUDIO_CACHE_DIR"
|
||||
echo ""
|
||||
# shellcheck disable=SC2086
|
||||
exec docker compose --profile "$PROFILE" up $DETACH
|
||||
;;
|
||||
down)
|
||||
if [[ -z "$PROFILE" ]]; then
|
||||
echo "ERROR: --profile is required with --down"
|
||||
exit 1
|
||||
fi
|
||||
echo "Stopping Audio Transcription containers..."
|
||||
exec docker compose --profile "$PROFILE" down
|
||||
;;
|
||||
logs)
|
||||
if [[ -z "$PROFILE" ]]; then
|
||||
echo "ERROR: --profile is required with --logs"
|
||||
exit 1
|
||||
fi
|
||||
exec docker compose --profile "$PROFILE" logs -f
|
||||
;;
|
||||
esac
|
||||
95
ai_platform/modules/audio/deploy/docker-compose.yml
Normal file
95
ai_platform/modules/audio/deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Audio Module - Docker Compose Configuration
|
||||
#
|
||||
# Port Allocation (Dev AI Audio: 54300):
|
||||
# 54300 - Audio API (Whisper STT service)
|
||||
#
|
||||
# Profiles:
|
||||
# api - API server only
|
||||
#
|
||||
# Required environment variables (set in deploy/.env file):
|
||||
# AUDIO_MODEL - Whisper model name (e.g., large-v3-turbo)
|
||||
# AUDIO_DEVICE - Device: cuda or cpu
|
||||
# AUDIO_COMPUTE_TYPE - Compute type: int8, float16, int8_float16
|
||||
# AUDIO_CACHE_DIR - Model cache directory
|
||||
#
|
||||
# GPU Configuration:
|
||||
# - Runs on GPU 0 (shared with Qwen3.5-35B-A3B)
|
||||
# - Requires ~6GB VRAM for large-v3-turbo with int8
|
||||
#
|
||||
# Naming Convention: didiAI-{module}-{service}
|
||||
#
|
||||
# Network:
|
||||
# Uses deploy_default network (shared with other modules)
|
||||
|
||||
networks:
|
||||
deploy_default:
|
||||
external: true
|
||||
|
||||
services:
|
||||
# ==========================================================================
|
||||
# Audio Transcription API Server
|
||||
# ==========================================================================
|
||||
audio-api:
|
||||
container_name: didiAI-audio-api
|
||||
image: didiai-audio-api
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
|
||||
ports:
|
||||
- "54300:54300"
|
||||
networks:
|
||||
- deploy_default
|
||||
|
||||
environment:
|
||||
# GPU configuration
|
||||
- CUDA_VISIBLE_DEVICES=0
|
||||
|
||||
# External URL for OpenAPI spec (REQUIRED)
|
||||
- AUDIO_EXTERNAL_URL=${AUDIO_EXTERNAL_URL}
|
||||
|
||||
# Whisper model configuration
|
||||
- AUDIO_MODEL=${AUDIO_MODEL:-large-v3-turbo}
|
||||
- AUDIO_DEVICE=${AUDIO_DEVICE:-cuda}
|
||||
- AUDIO_COMPUTE_TYPE=${AUDIO_COMPUTE_TYPE:-int8}
|
||||
- AUDIO_CACHE_DIR=${AUDIO_CACHE_DIR:-/root/.cache/huggingface}
|
||||
|
||||
# Transcription settings
|
||||
- AUDIO_BEAM_SIZE=${AUDIO_BEAM_SIZE:-5}
|
||||
- AUDIO_BEST_OF=${AUDIO_BEST_OF:-5}
|
||||
- AUDIO_TEMPERATURE=${AUDIO_TEMPERATURE:-0.0}
|
||||
|
||||
# Server settings
|
||||
- AUDIO_HOST=0.0.0.0
|
||||
- AUDIO_PORT=54300
|
||||
- AUDIO_LOG_LEVEL=${AUDIO_LOG_LEVEL:-INFO}
|
||||
|
||||
# Runtime config polling
|
||||
- AUDIO_DASHBOARD_URL=${AUDIO_DASHBOARD_URL:-http://didiAI-dashboard:51300}
|
||||
|
||||
# Upload limits
|
||||
- AUDIO_MAX_FILE_SIZE_MB=${AUDIO_MAX_FILE_SIZE_MB:-500}
|
||||
|
||||
volumes:
|
||||
# Model cache (shared with other modules)
|
||||
- ${AUDIO_CACHE_DIR:-/root/.cache/huggingface}:/root/.cache/huggingface
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
reservations:
|
||||
devices:
|
||||
- driver: nvidia
|
||||
device_ids: ['0']
|
||||
capabilities: [gpu]
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:54300/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- api
|
||||
|
||||
34
ai_platform/modules/audio/pyproject.toml
Normal file
34
ai_platform/modules/audio/pyproject.toml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
[project]
|
||||
name = "audio"
|
||||
version = "0.1.0"
|
||||
description = "Speech-to-text transcription service using faster-whisper"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"python-multipart>=0.0.9",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings>=2.0",
|
||||
"faster-whisper>=1.0.0",
|
||||
"prometheus-fastapi-instrumentator>=7.0.0",
|
||||
"opentelemetry-instrumentation-fastapi>=0.50b0",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.30.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=4.0",
|
||||
"ruff>=0.8",
|
||||
"httpx>=0.27.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/audio"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../ruff.toml"
|
||||
3
ai_platform/modules/audio/src/audio/__init__.py
Normal file
3
ai_platform/modules/audio/src/audio/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Audio transcription service using faster-whisper."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
523
ai_platform/modules/audio/src/audio/app.py
Normal file
523
ai_platform/modules/audio/src/audio/app.py
Normal file
|
|
@ -0,0 +1,523 @@
|
|||
import logging
|
||||
import tempfile
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import JSONResponse, PlainTextResponse
|
||||
|
||||
from .runtime_config import RuntimeConfigClient
|
||||
from .schemas import TranscriptionResponse, TranscriptionSegment
|
||||
from .settings import settings
|
||||
from .transcriber import get_transcriber
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Runtime config (instantiated here so it's reachable from request handlers via app.state)
|
||||
runtime_config = RuntimeConfigClient(
|
||||
dashboard_url=settings.dashboard_url,
|
||||
live_log_logger_name="audio",
|
||||
live_log_key="audio.log.level",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""App lifespan: load Whisper model + start runtime config polling."""
|
||||
logger.info("Starting Audio Transcription API")
|
||||
logger.info(f"Model: {settings.model}")
|
||||
logger.info(f"Device: {settings.device}")
|
||||
|
||||
# Initialize transcriber (loads model)
|
||||
get_transcriber()
|
||||
|
||||
await runtime_config.start()
|
||||
app.state.runtime_config = runtime_config
|
||||
|
||||
logger.info("Startup complete")
|
||||
yield
|
||||
|
||||
logger.info("Shutting down Audio Transcription API")
|
||||
await runtime_config.stop()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Audio Transcription API",
|
||||
description="Speech-to-text transcription using faster-whisper",
|
||||
version="0.1.0",
|
||||
servers=[{"url": settings.external_url, "description": "Audio Transcription API"}],
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- observability
|
||||
|
||||
|
||||
# Prometheus /metrics + OTel tracing (no-op if deps missing or OTEL endpoint unset)
|
||||
|
||||
|
||||
try:
|
||||
|
||||
|
||||
from prometheus_fastapi_instrumentator import Instrumentator as _Inst # type: ignore
|
||||
|
||||
|
||||
_Inst(should_group_status_codes=True).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
|
||||
|
||||
|
||||
except ImportError:
|
||||
|
||||
|
||||
pass
|
||||
|
||||
|
||||
import os as _os # noqa: E402
|
||||
|
||||
|
||||
_otel_ep = _os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
|
||||
|
||||
if _otel_ep:
|
||||
|
||||
|
||||
try:
|
||||
|
||||
|
||||
from opentelemetry import trace as _trace # type: ignore
|
||||
|
||||
|
||||
from opentelemetry.sdk.resources import Resource as _R # type: ignore
|
||||
|
||||
|
||||
from opentelemetry.sdk.trace import TracerProvider as _TP # type: ignore
|
||||
|
||||
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor as _BSP # type: ignore
|
||||
|
||||
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as _Exp # type: ignore
|
||||
|
||||
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as _FInst # type: ignore
|
||||
|
||||
|
||||
_provider = _TP(resource=_R.create({"service.name": _os.environ.get("OTEL_SERVICE_NAME", "didiAI-audio-api")}))
|
||||
|
||||
|
||||
_provider.add_span_processor(_BSP(_Exp(endpoint=_otel_ep, insecure=True)))
|
||||
|
||||
|
||||
_trace.set_tracer_provider(_provider)
|
||||
|
||||
|
||||
_FInst.instrument_app(app)
|
||||
|
||||
|
||||
print(f"[otel] didiAI-audio-api instrumented -> {_otel_ep}")
|
||||
|
||||
|
||||
except ImportError as _e:
|
||||
|
||||
|
||||
print(f"[otel] skip: {_e}")
|
||||
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/v1/models")
|
||||
def list_models():
|
||||
"""List available models (OpenAI-compatible)."""
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{
|
||||
"id": settings.model,
|
||||
"object": "model",
|
||||
"created": 1700000000,
|
||||
"owned_by": "openai",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def _download_url(url: str) -> tuple[bytes, str]:
|
||||
"""Download audio from URL. Returns (content, file_extension)."""
|
||||
parsed = urlparse(url)
|
||||
suffix = Path(parsed.path).suffix or ".mp3"
|
||||
|
||||
async with httpx.AsyncClient(follow_redirects=True, timeout=120.0) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
|
||||
return resp.content, suffix
|
||||
|
||||
|
||||
async def _get_audio_content(
|
||||
file: Optional[UploadFile],
|
||||
url: Optional[str],
|
||||
) -> tuple[bytes, str]:
|
||||
"""Get audio content from file upload or URL. Returns (content, suffix)."""
|
||||
if file and url:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Provide either 'file' or 'url', not both.",
|
||||
)
|
||||
|
||||
if url:
|
||||
try:
|
||||
content, suffix = await _download_url(url)
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to download URL ({e.response.status_code}): {url}",
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Failed to download URL: {e}",
|
||||
)
|
||||
elif file:
|
||||
content = await file.read()
|
||||
suffix = Path(file.filename).suffix if file.filename else ".mp3"
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either 'file' or 'url' is required.",
|
||||
)
|
||||
|
||||
file_size_mb = len(content) / (1024 * 1024)
|
||||
if file_size_mb > settings.max_file_size_mb:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"File too large: {file_size_mb:.1f}MB (max: {settings.max_file_size_mb}MB)",
|
||||
)
|
||||
|
||||
return content, suffix
|
||||
|
||||
|
||||
@app.post("/v1/audio/transcriptions")
|
||||
async def create_transcription(
|
||||
file: Optional[UploadFile] = File(default=None),
|
||||
url: Optional[str] = Form(default=None),
|
||||
model: str = Form(default=None),
|
||||
language: str = Form(default=None),
|
||||
prompt: str = Form(default=None),
|
||||
response_format: str = Form(default="json"),
|
||||
temperature: float = Form(default=0.0),
|
||||
):
|
||||
"""
|
||||
Transcribe audio file (OpenAI-compatible endpoint).
|
||||
|
||||
Accepts either a file upload or a URL to download.
|
||||
|
||||
Args:
|
||||
file: Audio file to transcribe (multipart upload)
|
||||
url: URL to download audio from (alternative to file upload)
|
||||
model: Model to use (ignored, uses configured model)
|
||||
language: Language code (auto-detect if None)
|
||||
prompt: Optional text prompt
|
||||
response_format: Response format: json, text, or verbose_json
|
||||
temperature: Sampling temperature
|
||||
|
||||
Returns:
|
||||
Transcription response in requested format
|
||||
"""
|
||||
content, suffix = await _get_audio_content(file, url)
|
||||
|
||||
# Save to temp location
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
|
||||
tmp_file.write(content)
|
||||
tmp_path = tmp_file.name
|
||||
|
||||
try:
|
||||
# Transcribe
|
||||
transcriber = get_transcriber()
|
||||
full_text, metadata = transcriber.transcribe(
|
||||
audio_path=tmp_path,
|
||||
language=language if language else None,
|
||||
initial_prompt=prompt if prompt else None,
|
||||
temperature=temperature,
|
||||
)
|
||||
|
||||
# Format response based on response_format
|
||||
if response_format == "text":
|
||||
return PlainTextResponse(content=full_text)
|
||||
|
||||
elif response_format == "verbose_json":
|
||||
# Verbose format with segments
|
||||
segments = [TranscriptionSegment(**seg) for seg in metadata["segments"]]
|
||||
response = TranscriptionResponse(
|
||||
text=full_text,
|
||||
language=metadata["language"],
|
||||
duration=metadata["duration"],
|
||||
segments=segments,
|
||||
)
|
||||
return JSONResponse(content=response.model_dump())
|
||||
|
||||
else:
|
||||
# Default JSON format (text only)
|
||||
response = TranscriptionResponse(
|
||||
text=full_text,
|
||||
language=metadata["language"],
|
||||
duration=metadata["duration"],
|
||||
)
|
||||
return JSONResponse(content=response.model_dump())
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Transcription error: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=f"Transcription failed: {str(e)}")
|
||||
|
||||
finally:
|
||||
# Clean up temp file
|
||||
try:
|
||||
Path(tmp_path).unlink()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@app.get("/v1/info")
|
||||
def get_component_info():
|
||||
"""
|
||||
Get component information for service catalog.
|
||||
|
||||
Returns complete metadata about this service including:
|
||||
- Resource information (component metadata)
|
||||
- Available models (Whisper variants)
|
||||
- Available functions (API endpoints)
|
||||
|
||||
This endpoint is used by the catalog-api to aggregate service information
|
||||
and by backend systems to populate the catalog database.
|
||||
|
||||
Returns:
|
||||
dict: Component information matching catalog.resources, catalog.models,
|
||||
and catalog.functions schemas.
|
||||
"""
|
||||
# Build resource information (maps to catalog.resources)
|
||||
resource = {
|
||||
"name": "Audio Transcription Service",
|
||||
"slug": "audio-transcription",
|
||||
"resource_type": "api_service",
|
||||
"provider": "internal",
|
||||
"base_url": f"http://audio-api:{settings.port}",
|
||||
"configuration": {
|
||||
"version": "0.1.0",
|
||||
"port": settings.port,
|
||||
"external_url": "http://localhost:8203",
|
||||
"model": settings.model,
|
||||
"device": settings.device,
|
||||
"compute_type": settings.compute_type,
|
||||
},
|
||||
"authentication": {
|
||||
"type": "none",
|
||||
"required": False,
|
||||
},
|
||||
"headers": {
|
||||
"Content-Type": "multipart/form-data",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
"rate_limits": {
|
||||
"enabled": False,
|
||||
},
|
||||
"cost_tracking": {
|
||||
"enabled": False,
|
||||
},
|
||||
"tags": ["audio", "transcription", "whisper", "speech-to-text", "openai-compatible"],
|
||||
"is_active": True,
|
||||
"metadata": {
|
||||
"category": "audio",
|
||||
"gpu_required": settings.device == "cuda",
|
||||
"status": "healthy",
|
||||
"backend": "faster-whisper",
|
||||
"max_file_size_mb": settings.max_file_size_mb,
|
||||
},
|
||||
}
|
||||
|
||||
# Define models (maps to catalog.models)
|
||||
models = [
|
||||
{
|
||||
"name": f"Whisper {settings.model}",
|
||||
"slug": f"whisper-{settings.model}",
|
||||
"provider": "openai",
|
||||
"model_type": "audio",
|
||||
"capabilities": [
|
||||
"transcription",
|
||||
"translation",
|
||||
"language-detection",
|
||||
"voice-activity-detection",
|
||||
],
|
||||
"configuration": {
|
||||
"backend": "faster-whisper",
|
||||
"device": settings.device,
|
||||
"compute_type": settings.compute_type,
|
||||
"beam_size": settings.beam_size,
|
||||
"best_of": settings.best_of,
|
||||
},
|
||||
"endpoint": "http://localhost:8203/v1/audio/transcriptions",
|
||||
"api_key_ref": None,
|
||||
"tags": ["whisper", "audio", "transcription", settings.model, settings.device],
|
||||
"is_active": True,
|
||||
"metadata": {
|
||||
"model_name": settings.model,
|
||||
"cache_dir": settings.cache_dir,
|
||||
"gpu_id": 1 if settings.device == "cuda" else None,
|
||||
"vram_gb": 7 if settings.device == "cuda" and "large" in settings.model else None,
|
||||
"quantization": settings.compute_type,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
# Define available functions (maps to catalog.functions)
|
||||
functions = [
|
||||
{
|
||||
"name": "Audio Transcription",
|
||||
"slug": "audio-transcription",
|
||||
"category": "transcription",
|
||||
"description": (
|
||||
"Transcribe audio files to text using Whisper. "
|
||||
"OpenAI-compatible endpoint supporting multiple response formats."
|
||||
),
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "file",
|
||||
"description": "Audio file to transcribe",
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Model identifier (ignored, uses configured model)",
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Language code (ISO 639-1). Auto-detect if not specified.",
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Optional text prompt to guide the model",
|
||||
},
|
||||
"response_format": {
|
||||
"type": "string",
|
||||
"enum": ["json", "text", "verbose_json"],
|
||||
"default": "json",
|
||||
"description": "Response format",
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"default": 0.0,
|
||||
"description": "Sampling temperature",
|
||||
},
|
||||
},
|
||||
"required": ["file"],
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "Transcribed text"},
|
||||
"language": {"type": "string", "description": "Detected language"},
|
||||
"duration": {"type": "number", "description": "Audio duration in seconds"},
|
||||
"segments": {
|
||||
"type": "array",
|
||||
"description": "Transcription segments (only in verbose_json format)",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "integer"},
|
||||
"start": {"type": "number"},
|
||||
"end": {"type": "number"},
|
||||
"text": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"implementation": {
|
||||
"method": "POST",
|
||||
"path": "/v1/audio/transcriptions",
|
||||
"content_type": "multipart/form-data",
|
||||
"timeout": 300,
|
||||
"max_file_size_mb": settings.max_file_size_mb,
|
||||
},
|
||||
"endpoint": "http://localhost:8203/v1/audio/transcriptions",
|
||||
"tags": ["audio", "transcription", "openai-compatible"],
|
||||
"is_active": True,
|
||||
"metadata": {
|
||||
"openai_compatible": True,
|
||||
"supports_streaming": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "List Audio Models",
|
||||
"slug": "audio-list-models",
|
||||
"category": "discovery",
|
||||
"description": "List available Whisper models (OpenAI-compatible)",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
"output_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"object": {"type": "string", "const": "list"},
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"object": {"type": "string"},
|
||||
"created": {"type": "integer"},
|
||||
"owned_by": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"implementation": {
|
||||
"method": "GET",
|
||||
"path": "/v1/models",
|
||||
"timeout": 5,
|
||||
},
|
||||
"endpoint": "http://localhost:8203/v1/models",
|
||||
"tags": ["discovery", "models"],
|
||||
"is_active": True,
|
||||
"metadata": {},
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
"resource": resource,
|
||||
"models": models,
|
||||
"functions": functions,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"audio.app:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level=settings.log_level.lower(),
|
||||
)
|
||||
157
ai_platform/modules/audio/src/audio/runtime_config.py
Normal file
157
ai_platform/modules/audio/src/audio/runtime_config.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""Runtime config client — fetches config overrides from dashboard.
|
||||
|
||||
Polls the dashboard /api/config endpoint periodically and caches values
|
||||
in memory. All accessors fall back to caller-supplied defaults if the
|
||||
dashboard is unreachable or the key is missing.
|
||||
|
||||
Single source of truth = dashboard `KNOWN_KEYS`. This client carries no
|
||||
local default registry; consumers pass their own fallback (typically the
|
||||
pydantic settings value).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuntimeConfigClient:
|
||||
"""Polls dashboard /api/config and caches values in-process."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dashboard_url: str | None,
|
||||
poll_interval_seconds: int = 30,
|
||||
request_timeout: float = 5.0,
|
||||
live_log_logger_name: str | None = None,
|
||||
live_log_key: str | None = None,
|
||||
) -> None:
|
||||
self.dashboard_url = dashboard_url.rstrip("/") if dashboard_url else None
|
||||
self.poll_interval = poll_interval_seconds
|
||||
self._timeout = request_timeout
|
||||
self._cache: dict[str, Any] = {}
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._enabled = bool(dashboard_url)
|
||||
# Optional auto-apply: re-set log level on the named logger when the
|
||||
# configured key changes value (e.g., `llm.log.level`).
|
||||
self._live_log_logger_name = live_log_logger_name
|
||||
self._live_log_key = live_log_key
|
||||
self._last_log_level: str | None = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
v = self._cache.get(key)
|
||||
return v if v is not None else default
|
||||
|
||||
def get_bool(self, key: str, default: bool = False) -> bool:
|
||||
v = self._cache.get(key)
|
||||
return bool(v) if v is not None else default
|
||||
|
||||
def get_int(self, key: str, default: int = 0) -> int:
|
||||
v = self._cache.get(key)
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def get_float(self, key: str, default: float = 0.0) -> float:
|
||||
v = self._cache.get(key)
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def get_str(self, key: str, default: str = "") -> str:
|
||||
v = self._cache.get(key)
|
||||
return str(v) if v is not None else default
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self._enabled:
|
||||
logger.info("RuntimeConfigClient disabled (no dashboard URL)")
|
||||
return
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(
|
||||
connect=2.0, read=self._timeout, write=2.0, pool=5.0
|
||||
)
|
||||
)
|
||||
# Fetch once synchronously so first requests already have overrides
|
||||
await self._refresh()
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
logger.info(
|
||||
"RuntimeConfigClient started (polling %s every %ds, %d keys cached)",
|
||||
self.dashboard_url,
|
||||
self.poll_interval,
|
||||
len(self._cache),
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await self._task
|
||||
self._task = None
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def _loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
await self._refresh()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("Config poll failed: %s", e)
|
||||
|
||||
async def _refresh(self) -> None:
|
||||
if self._client is None or self.dashboard_url is None:
|
||||
return
|
||||
try:
|
||||
resp = await self._client.get(f"{self.dashboard_url}/api/config")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.debug("Config refresh failed: %s", e)
|
||||
return
|
||||
|
||||
items = data.get("items", {})
|
||||
new_cache: dict[str, Any] = {}
|
||||
for key, entry in items.items():
|
||||
new_cache[key] = entry.get("value")
|
||||
self._cache = new_cache
|
||||
self._maybe_apply_log_level()
|
||||
|
||||
def _maybe_apply_log_level(self) -> None:
|
||||
"""Re-apply log level live if configured key changed."""
|
||||
if not self._live_log_key or not self._live_log_logger_name:
|
||||
return
|
||||
new_level = self.get_str(self._live_log_key)
|
||||
if not new_level:
|
||||
return
|
||||
if new_level == self._last_log_level:
|
||||
return
|
||||
try:
|
||||
level_int = logging.getLevelName(new_level.upper())
|
||||
if isinstance(level_int, int):
|
||||
logging.getLogger(self._live_log_logger_name).setLevel(level_int)
|
||||
self._last_log_level = new_level
|
||||
logger.info(
|
||||
"Log level for %s changed to %s (via runtime config)",
|
||||
self._live_log_logger_name,
|
||||
new_level,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to apply log level %s: %s", new_level, e)
|
||||
44
ai_platform/modules/audio/src/audio/schemas.py
Normal file
44
ai_platform/modules/audio/src/audio/schemas.py
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
from typing import Literal, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class TranscriptionSegment(BaseModel):
|
||||
"""A segment of transcribed audio."""
|
||||
|
||||
id: int = Field(..., description="Segment ID")
|
||||
seek: int = Field(..., description="Seek position in audio")
|
||||
start: float = Field(..., description="Start time in seconds")
|
||||
end: float = Field(..., description="End time in seconds")
|
||||
text: str = Field(..., description="Transcribed text")
|
||||
tokens: list[int] = Field(..., description="Token IDs")
|
||||
temperature: float = Field(..., description="Temperature used")
|
||||
avg_logprob: float = Field(..., description="Average log probability")
|
||||
compression_ratio: float = Field(..., description="Compression ratio")
|
||||
no_speech_prob: float = Field(..., description="No speech probability")
|
||||
|
||||
|
||||
class TranscriptionResponse(BaseModel):
|
||||
"""OpenAI-compatible transcription response."""
|
||||
|
||||
text: str = Field(..., description="Full transcription text")
|
||||
language: Optional[str] = Field(None, description="Detected language code")
|
||||
duration: Optional[float] = Field(None, description="Audio duration in seconds")
|
||||
segments: Optional[list[TranscriptionSegment]] = Field(None, description="Detailed segments")
|
||||
|
||||
|
||||
class TranscriptionRequest(BaseModel):
|
||||
"""Transcription request parameters."""
|
||||
|
||||
model: str = Field(default="large-v3-turbo", description="Whisper model to use")
|
||||
language: Optional[str] = Field(None, description="Language code (auto-detect if None)")
|
||||
prompt: Optional[str] = Field(None, description="Optional text prompt")
|
||||
response_format: Literal["json", "text", "verbose_json"] = Field(
|
||||
default="json",
|
||||
description="Response format"
|
||||
)
|
||||
temperature: float = Field(default=0.0, ge=0.0, le=1.0, description="Sampling temperature")
|
||||
timestamp_granularities: list[Literal["segment", "word"]] = Field(
|
||||
default=["segment"],
|
||||
description="Timestamp granularities"
|
||||
)
|
||||
47
ai_platform/modules/audio/src/audio/settings.py
Normal file
47
ai_platform/modules/audio/src/audio/settings.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="AUDIO_",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Whisper model configuration
|
||||
model: str = Field(default="large-v3-turbo", description="Whisper model name")
|
||||
device: str = Field(default="cuda", description="Device: cuda or cpu")
|
||||
compute_type: str = Field(default="int8", description="Compute type: float16, int8, int8_float16")
|
||||
|
||||
# Model cache
|
||||
cache_dir: str = Field(default="/root/.cache/huggingface", description="Model cache directory")
|
||||
|
||||
# Transcription settings
|
||||
beam_size: int = Field(default=5, ge=1, le=10, description="Beam size for decoding")
|
||||
best_of: int = Field(default=5, ge=1, le=10, description="Number of candidates when sampling")
|
||||
temperature: float = Field(default=0.0, ge=0.0, le=1.0, description="Temperature for sampling")
|
||||
|
||||
# Server settings
|
||||
host: str = Field(default="0.0.0.0")
|
||||
port: int = Field(default=54300)
|
||||
log_level: str = Field(default="INFO")
|
||||
external_url: str = Field(
|
||||
description="External URL for OpenAPI spec (e.g., http://10.11.10.42:54300). REQUIRED.",
|
||||
)
|
||||
|
||||
# Upload limits
|
||||
max_file_size_mb: int = Field(default=500, description="Max upload file size in MB")
|
||||
|
||||
# Runtime config (dashboard polling)
|
||||
dashboard_url: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional dashboard base URL. When set, runtime_config polls "
|
||||
"/api/config every 30s for live overrides."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
settings = Settings()
|
||||
106
ai_platform/modules/audio/src/audio/transcriber.py
Normal file
106
ai_platform/modules/audio/src/audio/transcriber.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
|
||||
from .settings import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Transcriber:
|
||||
"""Wrapper for faster-whisper transcription."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Whisper model."""
|
||||
logger.info(f"Loading Whisper model: {settings.model}")
|
||||
logger.info(f"Device: {settings.device}, Compute type: {settings.compute_type}")
|
||||
|
||||
self.model = WhisperModel(
|
||||
settings.model,
|
||||
device=settings.device,
|
||||
compute_type=settings.compute_type,
|
||||
download_root=settings.cache_dir,
|
||||
)
|
||||
|
||||
logger.info("Whisper model loaded successfully")
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
audio_path: str | Path,
|
||||
language: Optional[str] = None,
|
||||
initial_prompt: Optional[str] = None,
|
||||
temperature: float = 0.0,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Transcribe audio file.
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file
|
||||
language: Language code (None for auto-detection)
|
||||
initial_prompt: Optional prompt text
|
||||
temperature: Sampling temperature
|
||||
|
||||
Returns:
|
||||
Tuple of (full_text, metadata_dict)
|
||||
"""
|
||||
logger.info(f"Transcribing: {audio_path}")
|
||||
|
||||
segments, info = self.model.transcribe(
|
||||
str(audio_path),
|
||||
language=language,
|
||||
initial_prompt=initial_prompt,
|
||||
beam_size=settings.beam_size,
|
||||
best_of=settings.best_of,
|
||||
temperature=temperature,
|
||||
vad_filter=True,
|
||||
vad_parameters=dict(min_silence_duration_ms=500),
|
||||
)
|
||||
|
||||
# Collect segments
|
||||
all_segments = []
|
||||
full_text_parts = []
|
||||
|
||||
for segment in segments:
|
||||
all_segments.append({
|
||||
"id": segment.id,
|
||||
"seek": segment.seek,
|
||||
"start": segment.start,
|
||||
"end": segment.end,
|
||||
"text": segment.text,
|
||||
"tokens": segment.tokens,
|
||||
"temperature": segment.temperature,
|
||||
"avg_logprob": segment.avg_logprob,
|
||||
"compression_ratio": segment.compression_ratio,
|
||||
"no_speech_prob": segment.no_speech_prob,
|
||||
})
|
||||
full_text_parts.append(segment.text)
|
||||
|
||||
full_text = "".join(full_text_parts).strip()
|
||||
|
||||
metadata = {
|
||||
"language": info.language,
|
||||
"language_probability": info.language_probability,
|
||||
"duration": info.duration,
|
||||
"duration_after_vad": info.duration_after_vad,
|
||||
"all_language_probs": info.all_language_probs,
|
||||
"segments": all_segments,
|
||||
}
|
||||
|
||||
logger.info(f"Transcription complete: {len(all_segments)} segments, {info.duration:.2f}s")
|
||||
|
||||
return full_text, metadata
|
||||
|
||||
|
||||
# Global transcriber instance
|
||||
_transcriber: Optional[Transcriber] = None
|
||||
|
||||
|
||||
def get_transcriber() -> Transcriber:
|
||||
"""Get or create the global transcriber instance."""
|
||||
global _transcriber
|
||||
if _transcriber is None:
|
||||
_transcriber = Transcriber()
|
||||
return _transcriber
|
||||
0
ai_platform/modules/audio/tests/__init__.py
Normal file
0
ai_platform/modules/audio/tests/__init__.py
Normal file
225
ai_platform/modules/catalog-api/API.md
Normal file
225
ai_platform/modules/catalog-api/API.md
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
# Catalog API Documentation
|
||||
|
||||
Service catalog and discovery API that aggregates component information from all ML services.
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
{BASE_URL}
|
||||
```
|
||||
|
||||
- **Docker (internal):** `http://didiAI-catalog-api:11000`
|
||||
- **Via gateway:** `http://<host>:11000/catalog/`
|
||||
|
||||
## Authentication
|
||||
|
||||
No authentication required on direct access. When accessed via the gateway, Bearer token authentication is enforced by nginx.
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Health Check
|
||||
|
||||
```
|
||||
GET /health
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### List Components
|
||||
|
||||
List all registered components with full metadata (resource info, models, functions).
|
||||
|
||||
```
|
||||
GET /v1/components
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"components": [
|
||||
{
|
||||
"component_id": "llm-inference",
|
||||
"base_url": "http://didiAI-llm-api:14011",
|
||||
"resource": { "name": "LLM Inference Gateway", "slug": "llm-inference", ... },
|
||||
"models": [...],
|
||||
"functions": [...]
|
||||
}
|
||||
],
|
||||
"total": 4,
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Component
|
||||
|
||||
Get metadata for a specific component by ID.
|
||||
|
||||
```
|
||||
GET /v1/components/{component_id}
|
||||
```
|
||||
|
||||
**Path Parameters:**
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `component_id` | string | Component ID (e.g., `llm-inference`, `audio-transcription`, `video-analysis`, `web-factcheck`) |
|
||||
|
||||
**Response:** Same structure as a single component from `/v1/components`.
|
||||
|
||||
**Error (404):**
|
||||
```json
|
||||
{
|
||||
"detail": "Component 'unknown' not found"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### List Models
|
||||
|
||||
List all available models across all components.
|
||||
|
||||
```
|
||||
GET /v1/models
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "Qwen3.5-35B-A3B",
|
||||
"slug": "qwen3.5",
|
||||
"provider": "vllm",
|
||||
"model_type": "llm",
|
||||
"component_id": "llm-inference",
|
||||
"component_name": "LLM Inference Gateway"
|
||||
}
|
||||
],
|
||||
"total": 5
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### List Functions
|
||||
|
||||
List all available functions/endpoints across all components.
|
||||
|
||||
```
|
||||
GET /v1/functions
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"functions": [
|
||||
{
|
||||
"name": "Chat Completions",
|
||||
"slug": "chat-completions",
|
||||
"method": "POST",
|
||||
"path": "/v1/chat/completions",
|
||||
"component_id": "llm-inference"
|
||||
}
|
||||
],
|
||||
"total": 10
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### System Status
|
||||
|
||||
Aggregated health status of all components.
|
||||
|
||||
```
|
||||
GET /v1/status
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"components": [
|
||||
{
|
||||
"component_id": "llm-inference",
|
||||
"url": "http://didiAI-llm-api:14011",
|
||||
"reachable": true,
|
||||
"healthy": true,
|
||||
"status_code": 200
|
||||
}
|
||||
],
|
||||
"healthy_count": 4,
|
||||
"total_count": 4
|
||||
}
|
||||
```
|
||||
|
||||
**Status values:** `healthy` (all ok), `degraded` (some ok), `unhealthy` (none ok).
|
||||
|
||||
---
|
||||
|
||||
### Aggregated OpenAPI Spec
|
||||
|
||||
Combined OpenAPI 3.1.0 specification from all components.
|
||||
|
||||
```
|
||||
GET /v1/openapi
|
||||
```
|
||||
|
||||
**Response:** Full OpenAPI JSON spec with paths prefixed by `/{component_id}` and schemas prefixed by `{component_id}_`.
|
||||
|
||||
---
|
||||
|
||||
### Swagger UI
|
||||
|
||||
Interactive API documentation (Swagger UI) for the aggregated spec.
|
||||
|
||||
```
|
||||
GET /v1/docs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### ReDoc
|
||||
|
||||
Alternative API documentation (ReDoc) for the aggregated spec.
|
||||
|
||||
```
|
||||
GET /v1/redoc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Component OpenAPI Spec
|
||||
|
||||
Raw OpenAPI spec for a single component.
|
||||
|
||||
```
|
||||
GET /v1/openapi/component/{component_id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| 404 | Component not found |
|
||||
| 500 | Internal error (component unreachable) |
|
||||
|
||||
## Request Headers
|
||||
|
||||
| Header | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `Accept` | No | `application/json` (default) |
|
||||
146
ai_platform/modules/catalog-api/INDEX.md
Normal file
146
ai_platform/modules/catalog-api/INDEX.md
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
# catalog-api — INDEX
|
||||
|
||||
Catalog API for the DIDI AI platform. It is a **service registry and discovery gateway**: it aggregates `/v1/info` responses from each ML component (LLM, Audio, Video, Web) and re-exposes a unified view (components, models, functions, status) plus a merged OpenAPI 3.1 spec with Swagger UI / ReDoc. Production port `11000`, container `didiAI-catalog-api`. Read-only HTTP aggregator — no database, no writes.
|
||||
|
||||
- **Stack:** Python 3.11, FastAPI, httpx (async), pydantic-settings, uv, uvicorn
|
||||
- **Container:** `didiAI-catalog-api`
|
||||
- **Internal URL:** `http://didiAI-catalog-api:11000`
|
||||
- **Production host:** `http://10.11.10.42` (per `CATALOG_EXTERNAL_URL` in `deploy/.env.example`)
|
||||
- **Network:** Docker external network `didi-network` (shared with the other ai_platform modules)
|
||||
- **Sister CLAUDE.md (platform):** `/home/admin365/didi_mono/ai_platform/CLAUDE.md`
|
||||
|
||||
> Note: despite the name, this module is **not** a knowledge-graph / atom catalog. It is a *service catalog* (think "service registry" in the microservices sense). It does not talk to PostgreSQL, Atomic, Redis, or didi-brain.
|
||||
|
||||
---
|
||||
|
||||
## Ce face
|
||||
|
||||
- Aggregates static metadata from each ML component by calling `GET /v1/info` on the configured backends.
|
||||
- Returns a single unified response with:
|
||||
- **Resources** — component descriptor (`name`, `slug`, `resource_type`, ...).
|
||||
- **Models** — every model exposed by every backend (LLM, Whisper, vision, etc.).
|
||||
- **Functions** — every endpoint/function each backend advertises.
|
||||
- Probes liveness of every backend and reports aggregated health (`healthy` / `degraded` / `unhealthy`).
|
||||
- Builds a **merged OpenAPI 3.1.0 document** (paths prefixed with `/{component_id}`, schemas prefixed with `{component_id}_`) and serves it as JSON, Swagger UI, and ReDoc — so a frontend or backend can consume a single contract for all GPU services.
|
||||
- Tolerant to partial outages: if a component is unreachable it is recorded under `errors` and skipped, the rest of the catalog still serves.
|
||||
|
||||
---
|
||||
|
||||
## API endpoints
|
||||
|
||||
Source: `src/catalog_api/app.py` and `API.md`.
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/health` | Liveness probe (`{"status":"ok"}`). |
|
||||
| GET | `/v1/components` | List all registered components with full metadata (resource + models + functions). |
|
||||
| GET | `/v1/components/{component_id}` | Full metadata for one component (404 if unknown). |
|
||||
| GET | `/v1/models` | Flattened list of every model across components. |
|
||||
| GET | `/v1/functions` | Flattened list of every function/endpoint across components. |
|
||||
| GET | `/v1/status` | Aggregated reachability + health of all components (`healthy_count`/`total_count`). |
|
||||
| GET | `/v1/openapi` | Merged OpenAPI 3.1 spec (all components, prefixed). |
|
||||
| GET | `/v1/docs` | Swagger UI for the merged spec. |
|
||||
| GET | `/v1/redoc` | ReDoc for the merged spec. |
|
||||
| GET | `/v1/openapi/component/{component_id}` | Raw OpenAPI spec of a single component (passthrough). |
|
||||
|
||||
Status values from `/v1/status`: `healthy` (all reachable), `degraded` (some reachable), `unhealthy` (none reachable).
|
||||
|
||||
When accessed through the gateway, Bearer-token auth is enforced by nginx (`<host>:11000/catalog/`); direct access is unauthenticated by design.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
+-----------------------------------------------------+
|
||||
| Catalog API (:11000) — didiAI-catalog-api |
|
||||
| - Calls /v1/info on each backend |
|
||||
| - Merges OpenAPI specs, exposes Swagger UI / ReDoc |
|
||||
| - Stateless, no DB |
|
||||
+-------+----------+----------+----------+------------+
|
||||
| | | |
|
||||
v v v v
|
||||
LLM 14011 Audio 54300 Video 54600 Web 51100
|
||||
didiAI-llm-api -audio-api -video-api -web-api
|
||||
```
|
||||
|
||||
- **Pure aggregator** — no persistence, no caching layer; a fresh fan-out happens per request via `httpx.AsyncClient`.
|
||||
- **Component list is config-driven** (`CatalogSettings.get_components()` in `settings.py`): a component with an empty URL is silently dropped (used today to disable Video by setting `CATALOG_VIDEO_URL=""`).
|
||||
- **External-vs-internal URL split** — internal URLs (`*_URL`) are used for live calls inside the Docker network; the `CATALOG_EXTERNAL_URL` + `*_EXTERNAL_PORT` pair is the public base URL injected into the merged OpenAPI `servers:` so that external clients hit the right hostnames/ports.
|
||||
|
||||
### How it is consumed
|
||||
|
||||
- **Frontend / API gateway** — fetches `/v1/openapi` to expose Swagger UI for the whole platform; fetches `/v1/status` for a system-health widget.
|
||||
- **Backend integrations** — pull `/v1/components` to populate their own catalog tables (`catalog.resources`, `catalog.models`, `catalog.functions`), as illustrated in `README.md` § Use Cases.
|
||||
- **Service discovery** — clients query `/v1/models` to find a model by `model_type` (e.g., all `vision` models) without hard-coding hosts.
|
||||
|
||||
---
|
||||
|
||||
## Structura fișiere
|
||||
|
||||
```
|
||||
catalog-api/
|
||||
├── README.md Overview, quick-start, configuration, use cases
|
||||
├── API.md Endpoint reference (request/response shapes)
|
||||
├── INDEX.md This file
|
||||
├── pyproject.toml Hatchling package, FastAPI/httpx/pydantic-settings deps
|
||||
└── src/catalog_api/
|
||||
├── __init__.py version = "0.1.0"
|
||||
├── app.py FastAPI app — all endpoints + OpenAPI merger (~21 KB, single module)
|
||||
└── settings.py CatalogSettings (env prefix CATALOG_) and Component model
|
||||
└── deploy/
|
||||
├── Dockerfile Multi-stage build: python:3.11.12-slim + uv 0.10, runs uvicorn
|
||||
├── docker-compose.yml Defines didiAI-catalog-api on didi-network, expose:11000 only
|
||||
├── deploy.sh Wrapper: loads .env, validates CATALOG_EXTERNAL_URL, runs compose
|
||||
├── .env.example Documented environment variables
|
||||
└── .env Local environment (CATALOG_EXTERNAL_URL=...)
|
||||
```
|
||||
|
||||
Implementation footprint is tiny: one `app.py` (all endpoints + OpenAPI merger live there) plus one `settings.py`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings come from environment variables with prefix `CATALOG_` (see `src/catalog_api/settings.py`).
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `CATALOG_HOST` | `0.0.0.0` | Bind address. |
|
||||
| `CATALOG_PORT` | `11000` | Bind port. |
|
||||
| `CATALOG_LOG_LEVEL` | `INFO` | Python logging level. |
|
||||
| `CATALOG_EXTERNAL_URL` | **required** | Public base URL (e.g., `http://10.11.10.42`) injected into the merged OpenAPI `servers:`. `deploy.sh` aborts if missing. |
|
||||
| `CATALOG_LLM_URL` | `http://didiAI-llm-api:14011` | LLM Inference internal URL. |
|
||||
| `CATALOG_AUDIO_URL` | `http://didiAI-audio-api:54300` | Audio API internal URL. |
|
||||
| `CATALOG_VIDEO_URL` | *(empty)* | Video Analysis internal URL — empty string disables Video. |
|
||||
| `CATALOG_WEB_URL` | `http://didiAI-web-api:51100` | Web API internal URL. |
|
||||
| `CATALOG_LLM_EXTERNAL_PORT` | `14011` | External port advertised in the merged OpenAPI for LLM. |
|
||||
| `CATALOG_AUDIO_EXTERNAL_PORT` | `54300` | External port for Audio. |
|
||||
| `CATALOG_VIDEO_EXTERNAL_PORT` | `54600` | External port for Video. |
|
||||
| `CATALOG_WEB_EXTERNAL_PORT` | `51100` | External port for Web. |
|
||||
| `CATALOG_COMPONENT_TIMEOUT` | `10` | Per-call httpx timeout in seconds. |
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Docker compose** (`deploy/docker-compose.yml`): builds `didiai-catalog-api`, attaches to external network `didi-network`, only `expose: 11000` (no host port — traffic comes through the platform gateway). Healthcheck hits `http://localhost:11000/health` every 30 s, restart policy `unless-stopped`.
|
||||
- **Dockerfile** (`deploy/Dockerfile`): two-stage build using `ghcr.io/astral-sh/uv:0.10` for dependency install, then a slim `python:3.11.12-slim` runtime that runs `python -m uvicorn catalog_api.app:app`.
|
||||
- **Helper script** (`deploy/deploy.sh`): loads `.env`, validates `CATALOG_EXTERNAL_URL`, supports `--detach`, `--down`, `--logs`.
|
||||
- **Local dev** (per README.md): `uv sync && uv run python -m uvicorn catalog_api.app:app --host 0.0.0.0 --port 11000` (requires the listed components reachable on the network).
|
||||
|
||||
---
|
||||
|
||||
## Related modules
|
||||
|
||||
This service stands on top of the rest of the `ai_platform/modules/*` family — they are its data sources:
|
||||
|
||||
- `llm-inference` (port `14011`, container `didiAI-llm-api`) — chat, embeddings, rerank.
|
||||
- `audio` (port `54300`, container `didiAI-audio-api`) — transcription / TTS.
|
||||
- `video-analysis` (port `54600`, container `didiAI-video-api`) — vision pipelines (currently disabled by default in the .env example).
|
||||
- `web` (port `51100`, container `didiAI-web-api`) — fact-check / web crawler.
|
||||
- `dashboard` — primary frontend consumer of the merged OpenAPI / `/v1/status`.
|
||||
|
||||
It is **independent** of:
|
||||
- `didi-brain`, Atomic / knowledge-graph services, PostgreSQL, Redis — none of these are accessed.
|
||||
- The orchestration-layer (`agent-v3`) does not currently consume this catalog; it talks to the GPU services directly.
|
||||
253
ai_platform/modules/catalog-api/README.md
Normal file
253
ai_platform/modules/catalog-api/README.md
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
# Catalog API
|
||||
|
||||
**Service catalog and discovery** - aggregates component information from all ML services.
|
||||
|
||||
## What It Does
|
||||
|
||||
This module provides a unified API to discover and query all available ML services, models, and endpoints in the system. It aggregates `/v1/info` from all registered components and exposes:
|
||||
|
||||
- Component metadata (resources)
|
||||
- Available models across all services
|
||||
- Available functions/endpoints
|
||||
- Health status of all components
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**Required:**
|
||||
- All global prerequisites (see main [README.md](../../README.md))
|
||||
- Docker network `deploy_default` (shared with other modules)
|
||||
- At least one other module running (llm-inference, audio, video-analysis, or web)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd deploy/
|
||||
|
||||
# Start the catalog API
|
||||
docker compose up -d
|
||||
|
||||
# Check health
|
||||
curl http://localhost:11000/health
|
||||
|
||||
# List all components
|
||||
curl http://localhost:11000/v1/components | jq
|
||||
|
||||
# List all models
|
||||
curl http://localhost:11000/v1/models | jq
|
||||
|
||||
# Get component status
|
||||
curl http://localhost:11000/v1/status | jq
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Description |
|
||||
|----------|--------|-------------|
|
||||
| `/health` | GET | Health check |
|
||||
| `/v1/components` | GET | List all components with full metadata |
|
||||
| `/v1/components/{component_id}` | GET | Get specific component info |
|
||||
| `/v1/models` | GET | List all available models |
|
||||
| `/v1/functions` | GET | List all available functions/endpoints |
|
||||
| `/v1/status` | GET | Aggregated health status |
|
||||
| `/v1/openapi` | GET | Aggregated OpenAPI 3.1.0 spec (all components) |
|
||||
| `/v1/docs` | GET | Swagger UI for aggregated API |
|
||||
| `/v1/redoc` | GET | ReDoc for aggregated API |
|
||||
| `/v1/openapi/component/{component_id}` | GET | OpenAPI spec for a single component |
|
||||
|
||||
## Configuration
|
||||
|
||||
Configure via environment variables (prefix: `CATALOG_`):
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CATALOG_HOST` | `0.0.0.0` | Server host |
|
||||
| `CATALOG_PORT` | `11000` | Server port (Production API Gateway: 11000) |
|
||||
| `CATALOG_LOG_LEVEL` | `INFO` | Log level |
|
||||
| `CATALOG_LLM_URL` | `http://didiAI-llm-api:14011` | LLM Inference API URL |
|
||||
| `CATALOG_AUDIO_URL` | `http://didiAI-audio-api:54300` | Audio API URL |
|
||||
| `CATALOG_VIDEO_URL` | `http://didiAI-video-api:54600` | Video Analysis API URL |
|
||||
| `CATALOG_WEB_URL` | `http://didiAI-web-api:51100` | Web API URL |
|
||||
| `CATALOG_COMPONENT_TIMEOUT` | `10` | Component request timeout (seconds) |
|
||||
|
||||
## Example Responses
|
||||
|
||||
### List Components
|
||||
|
||||
```bash
|
||||
curl http://localhost:11000/v1/components | jq
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"components": [
|
||||
{
|
||||
"component_id": "llm-inference",
|
||||
"base_url": "http://didiAI-llm-api:14011",
|
||||
"resource": {
|
||||
"name": "LLM Inference Gateway",
|
||||
"slug": "llm-inference",
|
||||
"resource_type": "api_service",
|
||||
...
|
||||
},
|
||||
"models": [...],
|
||||
"functions": [...]
|
||||
},
|
||||
{
|
||||
"component_id": "audio-transcription",
|
||||
...
|
||||
}
|
||||
],
|
||||
"total": 4,
|
||||
"errors": null
|
||||
}
|
||||
```
|
||||
|
||||
### List All Models
|
||||
|
||||
```bash
|
||||
curl http://localhost:11000/v1/models | jq
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"models": [
|
||||
{
|
||||
"name": "Qwen3.5-35B-A3B",
|
||||
"slug": "qwen3.5",
|
||||
"provider": "vllm",
|
||||
"model_type": "llm",
|
||||
"component_id": "llm-inference",
|
||||
"component_name": "LLM Inference Gateway",
|
||||
...
|
||||
},
|
||||
{
|
||||
"name": "Whisper large-v3-turbo",
|
||||
"component_id": "audio-transcription",
|
||||
...
|
||||
}
|
||||
],
|
||||
"total": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Component Status
|
||||
|
||||
```bash
|
||||
curl http://localhost:11000/v1/status | jq
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"components": [
|
||||
{
|
||||
"component_id": "llm-inference",
|
||||
"url": "http://didiAI-llm-api:14011",
|
||||
"reachable": true,
|
||||
"healthy": true,
|
||||
"status_code": 200
|
||||
},
|
||||
{
|
||||
"component_id": "audio-transcription",
|
||||
"reachable": true,
|
||||
"healthy": true,
|
||||
"status_code": 200
|
||||
}
|
||||
],
|
||||
"healthy_count": 4,
|
||||
"total_count": 4
|
||||
}
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. Backend System Integration
|
||||
|
||||
Your backend can pull all service metadata and populate the database:
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Pull all components
|
||||
response = requests.get("http://localhost:11000/v1/components")
|
||||
components = response.json()["components"]
|
||||
|
||||
for comp in components:
|
||||
# Populate catalog.resources
|
||||
db.insert_resource(comp["resource"])
|
||||
|
||||
# Populate catalog.models
|
||||
for model in comp.get("models", []):
|
||||
db.insert_model(model)
|
||||
|
||||
# Populate catalog.functions
|
||||
for function in comp.get("functions", []):
|
||||
db.insert_function(function)
|
||||
```
|
||||
|
||||
### 2. Service Discovery
|
||||
|
||||
```python
|
||||
# Find all vision models
|
||||
response = requests.get("http://localhost:11000/v1/models")
|
||||
models = response.json()["models"]
|
||||
|
||||
vision_models = [m for m in models if m["model_type"] == "vision"]
|
||||
print(f"Found {len(vision_models)} vision models")
|
||||
```
|
||||
|
||||
### 3. Health Monitoring
|
||||
|
||||
```python
|
||||
# Check system health
|
||||
response = requests.get("http://localhost:11000/v1/status")
|
||||
status = response.json()
|
||||
|
||||
if status["status"] != "healthy":
|
||||
alert(f"System degraded: {status['healthy_count']}/{status['total_count']} healthy")
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
+-----------------------------------------------------+
|
||||
| Catalog API (11000) |
|
||||
| - Aggregates /v1/info from all components |
|
||||
| - No database, just HTTP aggregation |
|
||||
| - Read-only, no writes |
|
||||
+----------------+------------------------------------+
|
||||
|
|
||||
+-----------+-----------+-----------+
|
||||
v v v v
|
||||
+--------+ +---------+ +--------+ +----------+
|
||||
| LLM | | Audio | | Video | | Web |
|
||||
| 14011 | | 54300 | | 54600 | | 51100 |
|
||||
+--------+ +---------+ +--------+ +----------+
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
uv sync
|
||||
|
||||
# Run locally (requires components running)
|
||||
uv run python -m uvicorn catalog_api.app:app --host 0.0.0.0 --port 11000
|
||||
|
||||
# Test
|
||||
curl http://localhost:11000/v1/components | jq
|
||||
```
|
||||
|
||||
## Dependencies on Other Modules
|
||||
|
||||
This module aggregates information from:
|
||||
- `llm-inference` (port 14011, Docker internal: didiAI-llm-api)
|
||||
- `audio` (port 54300, Docker internal: didiAI-audio-api)
|
||||
- `video-analysis` (port 54600, Docker internal: didiAI-video-api)
|
||||
- `web` (port 51100, Docker internal: didiAI-web-api)
|
||||
|
||||
**Note:** The catalog API can function with partial availability. If a component is unavailable, it will be skipped with a warning in the logs.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
32
ai_platform/modules/catalog-api/deploy/.env.example
Normal file
32
ai_platform/modules/catalog-api/deploy/.env.example
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# Catalog API Configuration
|
||||
# =============================================================================
|
||||
# Required Configuration (no defaults)
|
||||
# =============================================================================
|
||||
|
||||
# External URL for OpenAPI spec - REQUIRED
|
||||
# This is the URL that external clients will use to access the APIs
|
||||
CATALOG_EXTERNAL_URL=http://10.11.10.42
|
||||
|
||||
# =============================================================================
|
||||
# Optional Configuration (has defaults)
|
||||
# =============================================================================
|
||||
|
||||
# Server settings
|
||||
# CATALOG_HOST=0.0.0.0
|
||||
# CATALOG_PORT=11000
|
||||
# CATALOG_LOG_LEVEL=INFO
|
||||
|
||||
# Component URLs (Docker internal network)
|
||||
# CATALOG_LLM_URL=http://didiAI-llm-api:14011
|
||||
# CATALOG_AUDIO_URL=http://didiAI-audio-api:54300
|
||||
# CATALOG_VIDEO_URL=http://didiAI-video-api:54600
|
||||
# CATALOG_WEB_URL=http://didiAI-web-api:51100
|
||||
|
||||
# External ports for OpenAPI spec (match your docker-compose port mappings)
|
||||
# CATALOG_LLM_EXTERNAL_PORT=14011
|
||||
# CATALOG_AUDIO_EXTERNAL_PORT=54300
|
||||
# CATALOG_VIDEO_EXTERNAL_PORT=54600
|
||||
# CATALOG_WEB_EXTERNAL_PORT=51100
|
||||
|
||||
# HTTP client settings
|
||||
# CATALOG_COMPONENT_TIMEOUT=10
|
||||
39
ai_platform/modules/catalog-api/deploy/Dockerfile
Normal file
39
ai_platform/modules/catalog-api/deploy/Dockerfile
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# Catalog API - Dockerfile
|
||||
FROM python:3.11.12-slim AS builder
|
||||
|
||||
# Install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10 /uv /usr/local/bin/uv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency files
|
||||
COPY pyproject.toml ./
|
||||
|
||||
# Copy source code
|
||||
COPY src/ ./src/
|
||||
|
||||
# Install dependencies (no lockfile yet)
|
||||
RUN uv sync --no-dev
|
||||
|
||||
# Final stage
|
||||
FROM python:3.11.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy virtual environment from builder
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY src/ ./src/
|
||||
|
||||
# Set environment
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
ENV CATALOG_HOST=0.0.0.0
|
||||
ENV CATALOG_PORT=11000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
|
||||
CMD python -c "import urllib.request; import os; urllib.request.urlopen(f'http://localhost:{os.environ.get(\"CATALOG_PORT\", 11000)}/health')" || exit 1
|
||||
|
||||
EXPOSE 11000
|
||||
|
||||
CMD python -m uvicorn catalog_api.app:app --host 0.0.0.0 --port ${CATALOG_PORT}
|
||||
99
ai_platform/modules/catalog-api/deploy/deploy.sh
Normal file
99
ai_platform/modules/catalog-api/deploy/deploy.sh
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Docker Compose Startup Script for Catalog API
|
||||
#
|
||||
# Usage: ./deploy/deploy.sh [OPTIONS]
|
||||
#
|
||||
# Options:
|
||||
# --detach, -d Run in detached mode
|
||||
# --down Stop and remove containers
|
||||
# --logs Show logs
|
||||
# --help, -h Show this help message
|
||||
#
|
||||
# Required: Set environment variables in .env file or export them before running.
|
||||
# See .env.example for the full list of required variables.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Script directory
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
# Load .env file if it exists
|
||||
ENV_FILE=""
|
||||
if [[ -f "$SCRIPT_DIR/.env" ]]; then
|
||||
ENV_FILE="$SCRIPT_DIR/.env"
|
||||
elif [[ -f "$SCRIPT_DIR/../.env" ]]; then
|
||||
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||
fi
|
||||
|
||||
if [[ -n "$ENV_FILE" ]]; then
|
||||
echo "Loading environment from: $ENV_FILE"
|
||||
set -a
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
DETACH=""
|
||||
ACTION="up"
|
||||
|
||||
show_help() {
|
||||
sed -n '2,16p' "$0" | sed 's/^# //' | sed 's/^#//'
|
||||
exit 0
|
||||
}
|
||||
|
||||
check_required_var() {
|
||||
local var_name="$1"
|
||||
if [[ -z "${!var_name:-}" ]]; then
|
||||
echo "ERROR: Required environment variable $var_name is not set"
|
||||
echo "Set it in .env file or export it before running this script"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--detach|-d)
|
||||
DETACH="-d"
|
||||
shift
|
||||
;;
|
||||
--down)
|
||||
ACTION="down"
|
||||
shift
|
||||
;;
|
||||
--logs)
|
||||
ACTION="logs"
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
show_help
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Check required variables
|
||||
check_required_var "CATALOG_EXTERNAL_URL"
|
||||
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
case $ACTION in
|
||||
up)
|
||||
echo "Starting Catalog API"
|
||||
echo " External URL: $CATALOG_EXTERNAL_URL"
|
||||
echo ""
|
||||
# shellcheck disable=SC2086
|
||||
exec docker compose up $DETACH
|
||||
;;
|
||||
down)
|
||||
echo "Stopping Catalog API containers..."
|
||||
exec docker compose down
|
||||
;;
|
||||
logs)
|
||||
exec docker compose logs -f
|
||||
;;
|
||||
esac
|
||||
64
ai_platform/modules/catalog-api/deploy/docker-compose.yml
Normal file
64
ai_platform/modules/catalog-api/deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
# Catalog API - Docker Compose Configuration
|
||||
#
|
||||
# Port Allocation (Production API Gateway: 11000):
|
||||
# 11000 - Catalog API (main orchestrator gateway)
|
||||
#
|
||||
# Network:
|
||||
# Uses deploy_default network (shared with other modules)
|
||||
#
|
||||
# Naming Convention: didiAI-{module}-{service}
|
||||
|
||||
networks:
|
||||
didi-network:
|
||||
external: true # single shared network for all DIDI + AI platform stacks
|
||||
|
||||
services:
|
||||
catalog-api:
|
||||
container_name: didiAI-catalog-api
|
||||
image: didiai-catalog-api
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
|
||||
# No external port - accessible only via Gateway (:11000/catalog/)
|
||||
expose:
|
||||
- "11000"
|
||||
|
||||
networks:
|
||||
- didi-network
|
||||
|
||||
environment:
|
||||
# Server settings
|
||||
- CATALOG_HOST=0.0.0.0
|
||||
- CATALOG_PORT=11000
|
||||
- CATALOG_LOG_LEVEL=${CATALOG_LOG_LEVEL:-INFO}
|
||||
|
||||
# External URL for OpenAPI spec (REQUIRED - no default)
|
||||
- CATALOG_EXTERNAL_URL=${CATALOG_EXTERNAL_URL}
|
||||
|
||||
# Component URLs (Docker internal network)
|
||||
- CATALOG_LLM_URL=${CATALOG_LLM_URL:-http://didiAI-llm-api:14011}
|
||||
- CATALOG_AUDIO_URL=${CATALOG_AUDIO_URL:-http://didiAI-audio-api:54300}
|
||||
- CATALOG_VIDEO_URL=${CATALOG_VIDEO_URL}
|
||||
- CATALOG_WEB_URL=${CATALOG_WEB_URL:-http://didiAI-web-api:51100}
|
||||
|
||||
# External ports for OpenAPI spec (used when generating external URLs)
|
||||
- CATALOG_LLM_EXTERNAL_PORT=${CATALOG_LLM_EXTERNAL_PORT:-14011}
|
||||
- CATALOG_AUDIO_EXTERNAL_PORT=${CATALOG_AUDIO_EXTERNAL_PORT:-54300}
|
||||
- CATALOG_VIDEO_EXTERNAL_PORT=${CATALOG_VIDEO_EXTERNAL_PORT:-54600}
|
||||
- CATALOG_WEB_EXTERNAL_PORT=${CATALOG_WEB_EXTERNAL_PORT:-51100}
|
||||
|
||||
# HTTP client settings
|
||||
- CATALOG_COMPONENT_TIMEOUT=${CATALOG_COMPONENT_TIMEOUT:-10}
|
||||
|
||||
# Runtime config polling
|
||||
- CATALOG_DASHBOARD_URL=${CATALOG_DASHBOARD_URL:-http://didiAI-dashboard:51300}
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:11000/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
restart: unless-stopped
|
||||
32
ai_platform/modules/catalog-api/pyproject.toml
Normal file
32
ai_platform/modules/catalog-api/pyproject.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
[project]
|
||||
name = "catalog-api"
|
||||
version = "0.1.0"
|
||||
description = "Service catalog API - aggregates component information"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.128.0",
|
||||
"uvicorn[standard]>=0.30.0",
|
||||
"httpx>=0.28.0",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings>=2.0",
|
||||
"prometheus-fastapi-instrumentator>=7.0.0",
|
||||
"opentelemetry-instrumentation-fastapi>=0.50b0",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.30.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-cov>=4.0",
|
||||
"ruff>=0.8",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/catalog_api"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../ruff.toml"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""Catalog API - Service Registry & Discovery."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
730
ai_platform/modules/catalog-api/src/catalog_api/app.py
Normal file
730
ai_platform/modules/catalog-api/src/catalog_api/app.py
Normal file
|
|
@ -0,0 +1,730 @@
|
|||
"""Catalog API - Service Registry & Discovery."""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from .runtime_config import RuntimeConfigClient
|
||||
from .settings import settings
|
||||
|
||||
# OpenAPI aggregation version
|
||||
AGGREGATED_OPENAPI_VERSION = "0.1.0"
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.log_level.upper()),
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Runtime config (reachable via app.state.runtime_config)
|
||||
runtime_config = RuntimeConfigClient(
|
||||
dashboard_url=settings.dashboard_url,
|
||||
live_log_logger_name="catalog_api",
|
||||
live_log_key="catalog.log.level",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""App lifespan: start runtime config polling."""
|
||||
logger.info("Starting Catalog API")
|
||||
await runtime_config.start()
|
||||
app.state.runtime_config = runtime_config
|
||||
yield
|
||||
logger.info("Shutting down Catalog API")
|
||||
await runtime_config.stop()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Catalog API",
|
||||
description="Service catalog and discovery - aggregates component information",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- observability
|
||||
|
||||
|
||||
# Prometheus /metrics + OTel tracing (no-op if deps missing or OTEL endpoint unset)
|
||||
|
||||
|
||||
try:
|
||||
|
||||
|
||||
from prometheus_fastapi_instrumentator import Instrumentator as _Inst # type: ignore
|
||||
|
||||
|
||||
_Inst(should_group_status_codes=True).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
|
||||
|
||||
|
||||
except ImportError:
|
||||
|
||||
|
||||
pass
|
||||
|
||||
|
||||
import os as _os # noqa: E402
|
||||
|
||||
|
||||
_otel_ep = _os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
|
||||
|
||||
if _otel_ep:
|
||||
|
||||
|
||||
try:
|
||||
|
||||
|
||||
from opentelemetry import trace as _trace # type: ignore
|
||||
|
||||
|
||||
from opentelemetry.sdk.resources import Resource as _R # type: ignore
|
||||
|
||||
|
||||
from opentelemetry.sdk.trace import TracerProvider as _TP # type: ignore
|
||||
|
||||
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor as _BSP # type: ignore
|
||||
|
||||
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as _Exp # type: ignore
|
||||
|
||||
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as _FInst # type: ignore
|
||||
|
||||
|
||||
_provider = _TP(resource=_R.create({"service.name": _os.environ.get("OTEL_SERVICE_NAME", "didiAI-catalog-api")}))
|
||||
|
||||
|
||||
_provider.add_span_processor(_BSP(_Exp(endpoint=_otel_ep, insecure=True)))
|
||||
|
||||
|
||||
_trace.set_tracer_provider(_provider)
|
||||
|
||||
|
||||
_FInst.instrument_app(app)
|
||||
|
||||
|
||||
print(f"[otel] didiAI-catalog-api instrumented -> {_otel_ep}")
|
||||
|
||||
|
||||
except ImportError as _e:
|
||||
|
||||
|
||||
print(f"[otel] skip: {_e}")
|
||||
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
"""Health check endpoint."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/v1/components")
|
||||
async def list_components() -> JSONResponse:
|
||||
"""
|
||||
List all registered components with their metadata.
|
||||
|
||||
Aggregates /v1/info from all configured components and returns
|
||||
a unified response containing:
|
||||
- Component resource information
|
||||
- Available models
|
||||
- Available functions/endpoints
|
||||
|
||||
Returns:
|
||||
JSONResponse: List of components with full metadata
|
||||
"""
|
||||
components = []
|
||||
errors = []
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for component in settings.get_components():
|
||||
try:
|
||||
logger.info(f"Fetching info from {component.id} at {component.url}/v1/info")
|
||||
response = await client.get(
|
||||
f"{component.url}/v1/info",
|
||||
timeout=component.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Add component ID to the response
|
||||
data["component_id"] = component.id
|
||||
data["base_url"] = component.url
|
||||
|
||||
components.append(data)
|
||||
logger.info(f"✓ Successfully fetched info from {component.id}")
|
||||
|
||||
except httpx.TimeoutException:
|
||||
error_msg = f"Timeout fetching {component.id}"
|
||||
logger.warning(error_msg)
|
||||
errors.append({"component_id": component.id, "error": "timeout", "url": component.url})
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_msg = f"HTTP {e.response.status_code} from {component.id}"
|
||||
logger.warning(error_msg)
|
||||
errors.append({
|
||||
"component_id": component.id,
|
||||
"error": f"http_{e.response.status_code}",
|
||||
"url": component.url,
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error fetching {component.id}: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
errors.append({
|
||||
"component_id": component.id,
|
||||
"error": str(e),
|
||||
"url": component.url,
|
||||
})
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"components": components,
|
||||
"total": len(components),
|
||||
"errors": errors if errors else None,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/components/{component_id}")
|
||||
async def get_component(component_id: str) -> JSONResponse:
|
||||
"""
|
||||
Get information for a specific component.
|
||||
|
||||
Args:
|
||||
component_id: Component identifier (e.g., "llm-inference", "audio-transcription")
|
||||
|
||||
Returns:
|
||||
JSONResponse: Component metadata
|
||||
|
||||
Raises:
|
||||
HTTPException: If component not found or unreachable
|
||||
"""
|
||||
# Find component configuration
|
||||
component_config = None
|
||||
for comp in settings.get_components():
|
||||
if comp.id == component_id:
|
||||
component_config = comp
|
||||
break
|
||||
|
||||
if not component_config:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Component '{component_id}' not found. Available: {[c.id for c in settings.get_components()]}",
|
||||
)
|
||||
|
||||
# Fetch component info
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{component_config.url}/v1/info",
|
||||
timeout=component_config.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Add component ID
|
||||
data["component_id"] = component_id
|
||||
data["base_url"] = component_config.url
|
||||
|
||||
return JSONResponse(content=data)
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail=f"Timeout fetching {component_id} from {component_config.url}",
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code,
|
||||
detail=f"HTTP error from {component_id}: {e.response.text}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error fetching {component_id}: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/models")
|
||||
async def list_models() -> JSONResponse:
|
||||
"""
|
||||
List all available models across all components.
|
||||
|
||||
Extracts model information from all components and returns
|
||||
a unified list with component attribution.
|
||||
|
||||
Returns:
|
||||
JSONResponse: List of all models with metadata
|
||||
"""
|
||||
all_models = []
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for component in settings.get_components():
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{component.url}/v1/info",
|
||||
timeout=component.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Extract models and add component context
|
||||
for model in data.get("models", []):
|
||||
model["component_id"] = component.id
|
||||
model["component_name"] = data.get("resource", {}).get("name", component.id)
|
||||
all_models.append(model)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping {component.id} due to error: {e}")
|
||||
continue
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"models": all_models,
|
||||
"total": len(all_models),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/functions")
|
||||
async def list_functions() -> JSONResponse:
|
||||
"""
|
||||
List all available functions/endpoints across all components.
|
||||
|
||||
Extracts function information from all components and returns
|
||||
a unified list with component attribution.
|
||||
|
||||
Returns:
|
||||
JSONResponse: List of all functions with metadata
|
||||
"""
|
||||
all_functions = []
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for component in settings.get_components():
|
||||
try:
|
||||
response = await client.get(
|
||||
f"{component.url}/v1/info",
|
||||
timeout=component.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Extract functions and add component context
|
||||
for function in data.get("functions", []):
|
||||
function["component_id"] = component.id
|
||||
function["component_name"] = data.get("resource", {}).get("name", component.id)
|
||||
all_functions.append(function)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping {component.id} due to error: {e}")
|
||||
continue
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"functions": all_functions,
|
||||
"total": len(all_functions),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/status")
|
||||
async def get_status() -> JSONResponse:
|
||||
"""
|
||||
Get aggregated status of all components.
|
||||
|
||||
Checks health/connectivity of all registered components
|
||||
and returns their status.
|
||||
|
||||
Returns:
|
||||
JSONResponse: Status summary for all components
|
||||
"""
|
||||
component_status = []
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for component in settings.get_components():
|
||||
status_entry = {
|
||||
"component_id": component.id,
|
||||
"url": component.url,
|
||||
"reachable": False,
|
||||
"healthy": False,
|
||||
}
|
||||
|
||||
try:
|
||||
# Try to fetch /v1/info
|
||||
response = await client.get(
|
||||
f"{component.url}/v1/info",
|
||||
timeout=component.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
status_entry["reachable"] = True
|
||||
status_entry["healthy"] = True
|
||||
status_entry["status_code"] = response.status_code
|
||||
|
||||
except httpx.TimeoutException:
|
||||
status_entry["error"] = "timeout"
|
||||
except httpx.HTTPStatusError as e:
|
||||
status_entry["reachable"] = True
|
||||
status_entry["status_code"] = e.response.status_code
|
||||
status_entry["error"] = f"http_{e.response.status_code}"
|
||||
except Exception as e:
|
||||
status_entry["error"] = str(e)
|
||||
|
||||
component_status.append(status_entry)
|
||||
|
||||
# Determine overall status
|
||||
healthy_count = sum(1 for s in component_status if s["healthy"])
|
||||
total_count = len(component_status)
|
||||
|
||||
if healthy_count == total_count:
|
||||
overall_status = "healthy"
|
||||
elif healthy_count > 0:
|
||||
overall_status = "degraded"
|
||||
else:
|
||||
overall_status = "unhealthy"
|
||||
|
||||
return JSONResponse(
|
||||
content={
|
||||
"status": overall_status,
|
||||
"components": component_status,
|
||||
"healthy_count": healthy_count,
|
||||
"total_count": total_count,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _merge_openapi_schemas(
|
||||
base_spec: dict[str, Any],
|
||||
component_spec: dict[str, Any],
|
||||
component_id: str,
|
||||
component_url: str,
|
||||
) -> None:
|
||||
"""
|
||||
Merge a component's OpenAPI spec into the base aggregated spec.
|
||||
|
||||
Args:
|
||||
base_spec: The aggregated OpenAPI spec to merge into (modified in place).
|
||||
component_spec: The component's OpenAPI spec to merge.
|
||||
component_id: Component identifier for prefixing paths.
|
||||
component_url: Component's base URL for server info.
|
||||
"""
|
||||
# Merge paths with component prefix
|
||||
component_paths = component_spec.get("paths", {})
|
||||
for path, path_item in component_paths.items():
|
||||
# Prefix path with component ID to avoid collisions
|
||||
prefixed_path = f"/{component_id}{path}"
|
||||
|
||||
# Deep copy to avoid modifying original
|
||||
new_path_item = copy.deepcopy(path_item)
|
||||
|
||||
# Add component tag to all operations
|
||||
for method in ["get", "post", "put", "delete", "patch", "options", "head"]:
|
||||
if method in new_path_item:
|
||||
operation = new_path_item[method]
|
||||
# Add component as a tag
|
||||
existing_tags = operation.get("tags", [])
|
||||
if component_id not in existing_tags:
|
||||
operation["tags"] = [component_id] + existing_tags
|
||||
# Update operationId to be unique
|
||||
if "operationId" in operation:
|
||||
operation["operationId"] = f"{component_id}_{operation['operationId']}"
|
||||
# Add server override for this path
|
||||
operation["servers"] = [{"url": component_url}]
|
||||
|
||||
base_spec["paths"][prefixed_path] = new_path_item
|
||||
|
||||
# Merge components/schemas with component prefix
|
||||
component_schemas = component_spec.get("components", {}).get("schemas", {})
|
||||
if "components" not in base_spec:
|
||||
base_spec["components"] = {}
|
||||
if "schemas" not in base_spec["components"]:
|
||||
base_spec["components"]["schemas"] = {}
|
||||
|
||||
for schema_name, schema_def in component_schemas.items():
|
||||
# Prefix schema name to avoid collisions
|
||||
prefixed_name = f"{component_id}_{schema_name}"
|
||||
base_spec["components"]["schemas"][prefixed_name] = copy.deepcopy(schema_def)
|
||||
|
||||
# Update $ref references in the schema
|
||||
_update_refs(base_spec["components"]["schemas"][prefixed_name], component_id)
|
||||
|
||||
# Update $refs in paths to use prefixed schema names (only for this component's paths)
|
||||
for path, path_item in base_spec["paths"].items():
|
||||
if path.startswith(f"/{component_id}"):
|
||||
for method_item in path_item.values():
|
||||
if isinstance(method_item, dict):
|
||||
_update_refs(method_item, component_id)
|
||||
|
||||
|
||||
def _update_refs(obj: Any, component_id: str) -> None:
|
||||
"""
|
||||
Recursively update $ref references to use component-prefixed schema names.
|
||||
|
||||
Args:
|
||||
obj: Object to update (modified in place).
|
||||
component_id: Component identifier for prefixing.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
for key, value in obj.items():
|
||||
if key == "$ref" and isinstance(value, str):
|
||||
# Update reference: #/components/schemas/Name -> #/components/schemas/component_Name
|
||||
if value.startswith("#/components/schemas/"):
|
||||
schema_name = value.split("/")[-1]
|
||||
obj[key] = f"#/components/schemas/{component_id}_{schema_name}"
|
||||
else:
|
||||
_update_refs(value, component_id)
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
_update_refs(item, component_id)
|
||||
|
||||
|
||||
@app.get("/v1/openapi")
|
||||
async def get_aggregated_openapi() -> JSONResponse:
|
||||
"""
|
||||
Get aggregated OpenAPI specification from all components.
|
||||
|
||||
Fetches /openapi.json from each registered component and merges them
|
||||
into a single unified OpenAPI 3.x specification.
|
||||
|
||||
Features:
|
||||
- Paths are prefixed with component ID (e.g., /llm-inference/v1/models)
|
||||
- Schemas are prefixed to avoid naming collisions
|
||||
- Each operation includes server override pointing to the actual component
|
||||
- Components are organized by tags
|
||||
|
||||
Returns:
|
||||
JSONResponse: Aggregated OpenAPI 3.1.0 specification
|
||||
"""
|
||||
# Base aggregated spec
|
||||
aggregated_spec: dict[str, Any] = {
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "didiAI - Aggregated ML Services API",
|
||||
"description": (
|
||||
"Unified OpenAPI specification aggregating all ML service endpoints.\n\n"
|
||||
"## Components\n"
|
||||
"- **llm-inference**: LLM inference gateway (chat completions, models)\n"
|
||||
"- **audio-transcription**: Speech-to-text transcription\n"
|
||||
"- **video-analysis**: Deepfake detection and semantic analysis\n"
|
||||
"- **web-factcheck**: Web search and evidence gathering\n"
|
||||
),
|
||||
"version": AGGREGATED_OPENAPI_VERSION,
|
||||
"contact": {"name": "didiAI Team"},
|
||||
},
|
||||
"servers": [
|
||||
{"url": f"{settings.external_url}:{settings.port}", "description": "Catalog API (aggregator)"},
|
||||
],
|
||||
"paths": {},
|
||||
"components": {"schemas": {}},
|
||||
"tags": [],
|
||||
}
|
||||
|
||||
errors = []
|
||||
successful_components = []
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
for component in settings.get_components():
|
||||
try:
|
||||
logger.info(f"Fetching OpenAPI from {component.id} at {component.url}/openapi.json")
|
||||
response = await client.get(
|
||||
f"{component.url}/openapi.json",
|
||||
timeout=component.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
component_spec = response.json()
|
||||
|
||||
# Add component as a tag
|
||||
component_info = component_spec.get("info", {})
|
||||
aggregated_spec["tags"].append({
|
||||
"name": component.id,
|
||||
"description": component_info.get("description", f"{component.id} API"),
|
||||
"externalDocs": {"url": f"{component.external_url}/docs"},
|
||||
})
|
||||
|
||||
# Merge component spec into aggregated (use external URL for OpenAPI)
|
||||
_merge_openapi_schemas(
|
||||
aggregated_spec,
|
||||
component_spec,
|
||||
component.id,
|
||||
component.external_url,
|
||||
)
|
||||
|
||||
successful_components.append(component.id)
|
||||
logger.info(f"✓ Successfully merged OpenAPI from {component.id}")
|
||||
|
||||
except httpx.TimeoutException:
|
||||
error_msg = f"Timeout fetching OpenAPI from {component.id}"
|
||||
logger.warning(error_msg)
|
||||
errors.append({"component_id": component.id, "error": "timeout"})
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_msg = f"HTTP {e.response.status_code} from {component.id}"
|
||||
logger.warning(error_msg)
|
||||
errors.append({"component_id": component.id, "error": f"http_{e.response.status_code}"})
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error fetching OpenAPI from {component.id}: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
errors.append({"component_id": component.id, "error": str(e)})
|
||||
|
||||
# Add metadata about aggregation
|
||||
aggregated_spec["info"]["x-aggregation"] = {
|
||||
"components_included": successful_components,
|
||||
"components_failed": [e["component_id"] for e in errors],
|
||||
"errors": errors if errors else None,
|
||||
}
|
||||
|
||||
return JSONResponse(content=aggregated_spec)
|
||||
|
||||
|
||||
@app.get("/v1/docs", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def get_aggregated_swagger_ui() -> HTMLResponse:
|
||||
"""
|
||||
Swagger UI for the aggregated OpenAPI specification.
|
||||
|
||||
Provides an interactive documentation interface for all ML services.
|
||||
"""
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>didiAI - API Documentation</title>
|
||||
<link rel="stylesheet" type="text/css" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css">
|
||||
<style>
|
||||
body {{ margin: 0; padding: 0; }}
|
||||
.swagger-ui .topbar {{ display: none; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-standalone-preset.js"></script>
|
||||
<script>
|
||||
window.onload = function() {{
|
||||
SwaggerUIBundle({{
|
||||
url: "/v1/openapi",
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "StandaloneLayout",
|
||||
plugins: [
|
||||
SwaggerUIBundle.plugins.DownloadUrl
|
||||
],
|
||||
deepLinking: true,
|
||||
showExtensions: true,
|
||||
showCommonExtensions: true,
|
||||
filter: true,
|
||||
tagsSorter: "alpha",
|
||||
operationsSorter: "alpha"
|
||||
}});
|
||||
}};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
|
||||
@app.get("/v1/redoc", response_class=HTMLResponse, include_in_schema=False)
|
||||
async def get_aggregated_redoc() -> HTMLResponse:
|
||||
"""
|
||||
ReDoc UI for the aggregated OpenAPI specification.
|
||||
|
||||
Provides a clean, readable documentation interface for all ML services.
|
||||
"""
|
||||
html_content = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>didiAI - API Documentation</title>
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||
<style>
|
||||
body { margin: 0; padding: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url='/v1/openapi'></redoc>
|
||||
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return HTMLResponse(content=html_content)
|
||||
|
||||
|
||||
@app.get("/v1/openapi/component/{component_id}")
|
||||
async def get_component_openapi(component_id: str) -> JSONResponse:
|
||||
"""
|
||||
Get OpenAPI specification for a specific component.
|
||||
|
||||
Args:
|
||||
component_id: Component identifier (e.g., "llm-inference", "audio-transcription")
|
||||
|
||||
Returns:
|
||||
JSONResponse: Component's OpenAPI specification
|
||||
|
||||
Raises:
|
||||
HTTPException: If component not found or unreachable
|
||||
"""
|
||||
# Find component configuration
|
||||
component_config = None
|
||||
for comp in settings.get_components():
|
||||
if comp.id == component_id:
|
||||
component_config = comp
|
||||
break
|
||||
|
||||
if not component_config:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Component '{component_id}' not found. Available: {[c.id for c in settings.get_components()]}",
|
||||
)
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(
|
||||
f"{component_config.url}/openapi.json",
|
||||
timeout=component_config.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return JSONResponse(content=response.json())
|
||||
|
||||
except httpx.TimeoutException:
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail=f"Timeout fetching OpenAPI from {component_id}",
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise HTTPException(
|
||||
status_code=e.response.status_code,
|
||||
detail=f"HTTP error from {component_id}: {e.response.text}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Error fetching OpenAPI from {component_id}: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"catalog_api.app:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level=settings.log_level.lower(),
|
||||
)
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
"""Runtime config client — fetches config overrides from dashboard.
|
||||
|
||||
Polls the dashboard /api/config endpoint periodically and caches values
|
||||
in memory. All accessors fall back to caller-supplied defaults if the
|
||||
dashboard is unreachable or the key is missing.
|
||||
|
||||
Single source of truth = dashboard `KNOWN_KEYS`. This client carries no
|
||||
local default registry; consumers pass their own fallback (typically the
|
||||
pydantic settings value).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RuntimeConfigClient:
|
||||
"""Polls dashboard /api/config and caches values in-process."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dashboard_url: str | None,
|
||||
poll_interval_seconds: int = 30,
|
||||
request_timeout: float = 5.0,
|
||||
live_log_logger_name: str | None = None,
|
||||
live_log_key: str | None = None,
|
||||
) -> None:
|
||||
self.dashboard_url = dashboard_url.rstrip("/") if dashboard_url else None
|
||||
self.poll_interval = poll_interval_seconds
|
||||
self._timeout = request_timeout
|
||||
self._cache: dict[str, Any] = {}
|
||||
self._client: httpx.AsyncClient | None = None
|
||||
self._task: asyncio.Task[None] | None = None
|
||||
self._enabled = bool(dashboard_url)
|
||||
# Optional auto-apply: re-set log level on the named logger when the
|
||||
# configured key changes value (e.g., `llm.log.level`).
|
||||
self._live_log_logger_name = live_log_logger_name
|
||||
self._live_log_key = live_log_key
|
||||
self._last_log_level: str | None = None
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def get(self, key: str, default: Any = None) -> Any:
|
||||
v = self._cache.get(key)
|
||||
return v if v is not None else default
|
||||
|
||||
def get_bool(self, key: str, default: bool = False) -> bool:
|
||||
v = self._cache.get(key)
|
||||
return bool(v) if v is not None else default
|
||||
|
||||
def get_int(self, key: str, default: int = 0) -> int:
|
||||
v = self._cache.get(key)
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def get_float(self, key: str, default: float = 0.0) -> float:
|
||||
v = self._cache.get(key)
|
||||
if v is None:
|
||||
return default
|
||||
try:
|
||||
return float(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def get_str(self, key: str, default: str = "") -> str:
|
||||
v = self._cache.get(key)
|
||||
return str(v) if v is not None else default
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self._enabled:
|
||||
logger.info("RuntimeConfigClient disabled (no dashboard URL)")
|
||||
return
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(
|
||||
connect=2.0, read=self._timeout, write=2.0, pool=5.0
|
||||
)
|
||||
)
|
||||
# Fetch once synchronously so first requests already have overrides
|
||||
await self._refresh()
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
logger.info(
|
||||
"RuntimeConfigClient started (polling %s every %ds, %d keys cached)",
|
||||
self.dashboard_url,
|
||||
self.poll_interval,
|
||||
len(self._cache),
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await self._task
|
||||
self._task = None
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
async def _loop(self) -> None:
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(self.poll_interval)
|
||||
await self._refresh()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.debug("Config poll failed: %s", e)
|
||||
|
||||
async def _refresh(self) -> None:
|
||||
if self._client is None or self.dashboard_url is None:
|
||||
return
|
||||
try:
|
||||
resp = await self._client.get(f"{self.dashboard_url}/api/config")
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.debug("Config refresh failed: %s", e)
|
||||
return
|
||||
|
||||
items = data.get("items", {})
|
||||
new_cache: dict[str, Any] = {}
|
||||
for key, entry in items.items():
|
||||
new_cache[key] = entry.get("value")
|
||||
self._cache = new_cache
|
||||
self._maybe_apply_log_level()
|
||||
|
||||
def _maybe_apply_log_level(self) -> None:
|
||||
"""Re-apply log level live if configured key changed."""
|
||||
if not self._live_log_key or not self._live_log_logger_name:
|
||||
return
|
||||
new_level = self.get_str(self._live_log_key)
|
||||
if not new_level:
|
||||
return
|
||||
if new_level == self._last_log_level:
|
||||
return
|
||||
try:
|
||||
level_int = logging.getLevelName(new_level.upper())
|
||||
if isinstance(level_int, int):
|
||||
logging.getLogger(self._live_log_logger_name).setLevel(level_int)
|
||||
self._last_log_level = new_level
|
||||
logger.info(
|
||||
"Log level for %s changed to %s (via runtime config)",
|
||||
self._live_log_logger_name,
|
||||
new_level,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to apply log level %s: %s", new_level, e)
|
||||
93
ai_platform/modules/catalog-api/src/catalog_api/settings.py
Normal file
93
ai_platform/modules/catalog-api/src/catalog_api/settings.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""Catalog API settings."""
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Component(BaseSettings):
|
||||
"""Component configuration."""
|
||||
|
||||
id: str
|
||||
url: str # Internal URL (Docker network)
|
||||
external_url: str # External URL for OpenAPI spec
|
||||
timeout: int = 10
|
||||
|
||||
|
||||
class CatalogSettings(BaseSettings):
|
||||
"""Catalog API configuration."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="CATALOG_",
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Server settings
|
||||
host: str = Field(default="0.0.0.0")
|
||||
port: int = Field(default=11000, description="Port (Catalog API: 11000)")
|
||||
log_level: str = Field(default="INFO")
|
||||
|
||||
# External URL for OpenAPI spec (required for external access)
|
||||
external_url: str = Field(
|
||||
description="External base URL for API (e.g., http://10.11.10.42). Required, no default.",
|
||||
)
|
||||
|
||||
# Components to aggregate (configured via docker network - internal API ports)
|
||||
llm_url: str = Field(
|
||||
default="http://didiAI-llm-api:14011",
|
||||
description="LLM Inference API URL (internal)",
|
||||
)
|
||||
audio_url: str = Field(
|
||||
default="http://didiAI-audio-api:54300",
|
||||
description="Audio API URL (internal)",
|
||||
)
|
||||
video_url: str = Field(
|
||||
default="",
|
||||
description="Video Analysis API URL (internal, empty to disable)",
|
||||
)
|
||||
web_url: str = Field(
|
||||
default="http://didiAI-web-api:51100",
|
||||
description="Web API URL (internal)",
|
||||
)
|
||||
|
||||
# External ports for components (used in OpenAPI spec)
|
||||
llm_external_port: int = Field(default=14011, description="LLM API external port")
|
||||
audio_external_port: int = Field(default=54300, description="Audio API external port")
|
||||
video_external_port: int = Field(default=54600, description="Video API external port")
|
||||
web_external_port: int = Field(default=51100, description="Web API external port")
|
||||
|
||||
# HTTP client settings
|
||||
component_timeout: int = Field(
|
||||
default=10,
|
||||
description="Timeout for component requests (seconds)",
|
||||
)
|
||||
|
||||
# Runtime config (dashboard polling)
|
||||
dashboard_url: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional dashboard base URL. When set, runtime_config polls "
|
||||
"/api/config every 30s for live overrides."
|
||||
),
|
||||
)
|
||||
|
||||
def get_components(self) -> list[Component]:
|
||||
"""Get list of configured components. Empty URLs are skipped."""
|
||||
all_components = [
|
||||
("llm-inference", self.llm_url, self.llm_external_port),
|
||||
("audio-transcription", self.audio_url, self.audio_external_port),
|
||||
("video-analysis", self.video_url, self.video_external_port),
|
||||
("web-factcheck", self.web_url, self.web_external_port),
|
||||
]
|
||||
return [
|
||||
Component(
|
||||
id=cid,
|
||||
url=url,
|
||||
external_url=f"{self.external_url}:{port}",
|
||||
timeout=self.component_timeout,
|
||||
)
|
||||
for cid, url, port in all_components
|
||||
if url
|
||||
]
|
||||
|
||||
|
||||
settings = CatalogSettings()
|
||||
56
ai_platform/modules/cloak/INDEX.md
Normal file
56
ai_platform/modules/cloak/INDEX.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# cloak — module index
|
||||
|
||||
## Purpose
|
||||
|
||||
HTTP wrapper around CloakBrowser. Scrapes Google / Bing / DDG SERPs and exposes
|
||||
the parsed organic results as JSON. Used as the third-tier fallback for the AI
|
||||
platform `web` module when SearXNG + paid rotation return thin results.
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
cloak/
|
||||
├── pyproject.toml FastAPI + uvicorn + cloakbrowser deps
|
||||
├── README.md User-facing docs
|
||||
├── INDEX.md This file (module map)
|
||||
├── src/cloak/
|
||||
│ ├── __init__.py
|
||||
│ ├── config.py Env-driven pydantic settings (CLOAK_* prefix)
|
||||
│ ├── schemas.py Request/Response/Stats pydantic models
|
||||
│ ├── scraper.py Per-engine HTML scrapers (google/bing/ddg)
|
||||
│ ├── browser_pool.py Bounded async pool of CloakBrowser instances
|
||||
│ └── server.py FastAPI app — POST /v1/search, GET /health
|
||||
├── tests/ pytest test suite
|
||||
└── deploy/
|
||||
├── Dockerfile FROM cloakhq/cloakbrowser:latest + FastAPI
|
||||
└── docker-compose.yml didiAI-cloak on didi-network, port 8770
|
||||
```
|
||||
|
||||
## External contracts
|
||||
|
||||
| Surface | Path | Method |
|
||||
|---|---|---|
|
||||
| Search | `/v1/search` | POST |
|
||||
| Health | `/health` | GET |
|
||||
|
||||
Both reachable inside the cluster at `http://didiAI-cloak:8770/`. Host-port
|
||||
`127.0.0.1:8770` is exposed only for local debugging on didi12.
|
||||
|
||||
## State
|
||||
|
||||
Stateless. No database, no Redis. The browser pool is in-process memory.
|
||||
Cold-start (warm 3 browsers) ≈ 8–12 s; from then on each search is 2–4 s
|
||||
end-to-end.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `cloakhq/cloakbrowser:latest` Docker base image (bundles stealth Chromium + Xvfb)
|
||||
- Outbound TCP to `google.com`, `bing.com`, `html.duckduckgo.com`
|
||||
|
||||
## Where it's consumed
|
||||
|
||||
- `ai_platform/modules/web/src/web/search/cloak.py` (CloakHTTPClient) — pending
|
||||
- `ai_platform/modules/web/src/web/orchestrator.py::_run_search_stage` — pending tier-3 hook
|
||||
|
||||
Backend services (`agent-v3`, `didi-framework`, admin-dashboard) do NOT call
|
||||
this service directly.
|
||||
113
ai_platform/modules/cloak/README.md
Normal file
113
ai_platform/modules/cloak/README.md
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# cloak — stealth-Chromium scraping service
|
||||
|
||||
Standalone HTTP service that scrapes Google / Bing / DuckDuckGo SERPs through a
|
||||
warm pool of CloakBrowser (patched stealth Chromium) instances.
|
||||
|
||||
Designed as **tier-3 search fallback** for the AI platform `web` module: when
|
||||
SearXNG + the paid rotation return thin results (often the case for very niche
|
||||
or recent queries), `cloak` provides results scraped directly from Google,
|
||||
Bing and DuckDuckGo's HTML SERPs.
|
||||
|
||||
## Why a separate service?
|
||||
|
||||
- **Isolated lifecycle.** Browser pool restarts don't take down the rest of the
|
||||
AI platform.
|
||||
- **Bounded footprint.** A small fixed pool (default 3 instances ≈ 1.2 GB RAM)
|
||||
versus N pools spreading across every web worker.
|
||||
- **Same deployment pattern** as `audio`, `embeddings`, `video-analysis` etc.
|
||||
|
||||
## API
|
||||
|
||||
```
|
||||
POST /v1/search
|
||||
{
|
||||
"queries": ["BNR confiscare conturi 10000 euro"],
|
||||
"engines": ["google", "bing", "ddg"],
|
||||
"max_results_per_engine": 10,
|
||||
"language": "ro" // optional hint
|
||||
}
|
||||
|
||||
200 OK
|
||||
{
|
||||
"results": [
|
||||
{"url": "...", "title": "...", "snippet": "...",
|
||||
"engine": "google", "query": "...", "rank": 1},
|
||||
...
|
||||
],
|
||||
"stats": [
|
||||
{"engine": "google", "query": "...", "results_count": 10,
|
||||
"blocked": false, "captcha": false, "elapsed_ms": 2750, "error": null},
|
||||
...
|
||||
],
|
||||
"total_elapsed_ms": 2900
|
||||
}
|
||||
|
||||
GET /health
|
||||
{
|
||||
"status": "healthy" | "degraded" | "unhealthy",
|
||||
"pool_size": 3, "pool_available": 3, "version": "0.1.0"
|
||||
}
|
||||
```
|
||||
|
||||
Auth is optional via `Authorization: Bearer <CLOAK_AUTH_TOKEN>`; when the env
|
||||
var is unset (default), all requests are accepted (intra-cluster service —
|
||||
should never be reachable from the internet).
|
||||
|
||||
## Configuration (env)
|
||||
|
||||
| Variable | Default | Notes |
|
||||
|---|---|---|
|
||||
| `CLOAK_HOST` | `0.0.0.0` | Bind address |
|
||||
| `CLOAK_PORT` | `8770` | HTTP port |
|
||||
| `CLOAK_POOL_SIZE` | `3` | Number of warm browsers (~400 MB each) |
|
||||
| `CLOAK_SEARCH_TIMEOUT_SEC` | `20` | Hard timeout for entire `/v1/search` call |
|
||||
| `CLOAK_PAGE_TIMEOUT_MS` | `18000` | Per-engine page load timeout |
|
||||
| `CLOAK_MAX_ENGINES` | `3` | Cap on engines per request |
|
||||
| `CLOAK_MAX_QUERIES` | `5` | Cap on queries per request |
|
||||
| `CLOAK_DEFAULT_MAX_RESULTS` | `10` | Default per-engine result cap |
|
||||
| `CLOAK_MAX_RESULTS_CAP` | `30` | Hard cap regardless of input |
|
||||
| `CLOAK_ENGINE_MIN_INTERVAL_MS` | `200` | Throttle between successive scrapes per engine |
|
||||
| `CLOAK_HUMANIZE` | `false` | Human-like mouse/keyboard timing (slower, better for behavioral anti-bot) |
|
||||
| `CLOAK_AUTH_TOKEN` | `""` | Optional bearer token. Empty = no auth |
|
||||
| `CLOAK_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
cd ai_platform/modules/cloak/deploy
|
||||
docker compose up -d --build
|
||||
docker logs -f didiAI-cloak
|
||||
# Health
|
||||
curl -s http://127.0.0.1:8770/health | jq
|
||||
# Smoke
|
||||
curl -sS -X POST http://127.0.0.1:8770/v1/search \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"queries":["NYTimes climate report 2026"],"engines":["google","bing","ddg"]}' | jq '.stats'
|
||||
```
|
||||
|
||||
## Where it fits
|
||||
|
||||
```
|
||||
agent-v3 / claims-verifier
|
||||
│
|
||||
▼
|
||||
didiAI-web-api ──► SearXNG (free, local) [tier 1 — always]
|
||||
+ Brave/Tavily/etc rotation [tier 2 — one paid per call]
|
||||
+ cloak (this service) [tier 3 — only if tier 1+2 thin]
|
||||
```
|
||||
|
||||
The web module's orchestrator decides when to invoke `cloak` based on the
|
||||
number of unique results returned from tiers 1+2. agent-v3 and didi-framework
|
||||
do not call `cloak` directly.
|
||||
|
||||
## Operational notes
|
||||
|
||||
- **Selectors break.** Google rotates its result-DOM classes every 6–12 months.
|
||||
The scraper has fallback selectors but the primary path will eventually need
|
||||
re-tuning. Monitor `stats.blocked` / `stats.results_count` over time.
|
||||
- **Rate limits.** No formal limit on the SERP endpoints, but bursts trigger
|
||||
captcha. Default `CLOAK_ENGINE_MIN_INTERVAL_MS=200` paces requests; tune up
|
||||
if you see captcha rates rise.
|
||||
- **CPU/RAM.** Each browser instance uses ~400 MB RAM and is single-CPU for
|
||||
most of a page load. The default `CLOAK_POOL_SIZE=3` is sized for didi12 ≤ 5k
|
||||
scrape ops/day; raise to 5–8 if pool starves the request queue.
|
||||
22
ai_platform/modules/cloak/deploy/Dockerfile
Normal file
22
ai_platform/modules/cloak/deploy/Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# cloak service — FastAPI wrapper around CloakBrowser
|
||||
#
|
||||
# Base image already has stealth Chromium, Xvfb, system fonts, Node 20.
|
||||
# We just add the FastAPI app + uvicorn on top.
|
||||
|
||||
FROM cloakhq/cloakbrowser:latest
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY src/ ./src/
|
||||
RUN pip install --no-cache-dir \
|
||||
"fastapi>=0.115.0" \
|
||||
"uvicorn[standard]>=0.32.0" \
|
||||
"pydantic>=2.0" \
|
||||
"pydantic-settings>=2.0" \
|
||||
&& pip install --no-cache-dir -e .
|
||||
|
||||
EXPOSE 8770
|
||||
|
||||
# Base image already runs Xvfb via /entrypoint.sh — we override the CMD only.
|
||||
CMD ["python", "-m", "cloak.server"]
|
||||
40
ai_platform/modules/cloak/deploy/docker-compose.yml
Normal file
40
ai_platform/modules/cloak/deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
services:
|
||||
cloak:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
image: didi-ai/cloak:latest
|
||||
container_name: didiAI-cloak
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- didi-network
|
||||
environment:
|
||||
CLOAK_HOST: 0.0.0.0
|
||||
CLOAK_PORT: 8770
|
||||
CLOAK_POOL_SIZE: ${CLOAK_POOL_SIZE:-3}
|
||||
CLOAK_SEARCH_TIMEOUT_SEC: ${CLOAK_SEARCH_TIMEOUT_SEC:-20}
|
||||
CLOAK_PAGE_TIMEOUT_MS: ${CLOAK_PAGE_TIMEOUT_MS:-18000}
|
||||
CLOAK_MAX_ENGINES: ${CLOAK_MAX_ENGINES:-3}
|
||||
CLOAK_MAX_QUERIES: ${CLOAK_MAX_QUERIES:-5}
|
||||
CLOAK_DEFAULT_MAX_RESULTS: ${CLOAK_DEFAULT_MAX_RESULTS:-10}
|
||||
CLOAK_MAX_RESULTS_CAP: ${CLOAK_MAX_RESULTS_CAP:-30}
|
||||
CLOAK_ENGINE_MIN_INTERVAL_MS: ${CLOAK_ENGINE_MIN_INTERVAL_MS:-200}
|
||||
CLOAK_HUMANIZE: ${CLOAK_HUMANIZE:-false}
|
||||
CLOAK_AUTH_TOKEN: ${CLOAK_AUTH_TOKEN:-}
|
||||
CLOAK_LOG_LEVEL: ${CLOAK_LOG_LEVEL:-INFO}
|
||||
DISPLAY: ":99"
|
||||
# Bind only to localhost on the host — intra-cluster access via DNS name
|
||||
# `didiAI-cloak` on didi-network. Public exposure is undesirable (scraping
|
||||
# endpoint should never be reachable from the internet).
|
||||
ports:
|
||||
- "127.0.0.1:8770:8770"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "python -c \"import urllib.request,sys; r=urllib.request.urlopen('http://127.0.0.1:8770/health', timeout=3); sys.exit(0 if r.status==200 else 1)\""]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
networks:
|
||||
didi-network:
|
||||
external: true
|
||||
33
ai_platform/modules/cloak/pyproject.toml
Normal file
33
ai_platform/modules/cloak/pyproject.toml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
[project]
|
||||
name = "cloak"
|
||||
version = "0.1.0"
|
||||
description = "Stealth Chromium scraping service — Google/Bing/DDG search via CloakBrowser, exposed as HTTP API."
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"pydantic>=2.0",
|
||||
"pydantic-settings>=2.0",
|
||||
# cloakbrowser is provided by the base Docker image (cloakhq/cloakbrowser:latest).
|
||||
# Listed in optional-dependencies for local dev only.
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0",
|
||||
"pytest-asyncio>=0.24",
|
||||
"httpx>=0.27.0",
|
||||
"ruff>=0.8",
|
||||
"cloakbrowser",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/cloak"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
3
ai_platform/modules/cloak/src/cloak/__init__.py
Normal file
3
ai_platform/modules/cloak/src/cloak/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Cloak — stealth scraping service for Google / Bing / DuckDuckGo search results."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
96
ai_platform/modules/cloak/src/cloak/browser_pool.py
Normal file
96
ai_platform/modules/cloak/src/cloak/browser_pool.py
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
"""Async pool of CloakBrowser instances kept warm.
|
||||
|
||||
The pool is a bounded asyncio.Queue of running Browser objects. acquire() and
|
||||
release() check out an instance for the lifetime of one search call. If a
|
||||
browser raises during use, we discard it and lazily replace it on the next
|
||||
acquire so a single bad page doesn't permanently shrink the pool.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
logger = logging.getLogger("cloak.pool")
|
||||
|
||||
|
||||
class BrowserPool:
|
||||
"""Bounded pool of CloakBrowser instances."""
|
||||
|
||||
def __init__(self, size: int, *, humanize: bool):
|
||||
self._size = size
|
||||
self._humanize = humanize
|
||||
self._queue: asyncio.Queue = asyncio.Queue(maxsize=size)
|
||||
self._created = 0
|
||||
self._lock = asyncio.Lock()
|
||||
self._closed = False
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return self._size
|
||||
|
||||
@property
|
||||
def available(self) -> int:
|
||||
return self._queue.qsize()
|
||||
|
||||
async def _create(self):
|
||||
"""Create one fresh browser. Imported lazily so tests can stub it."""
|
||||
from cloakbrowser import launch_async # type: ignore
|
||||
|
||||
browser = await launch_async(headless=True, humanize=self._humanize)
|
||||
self._created += 1
|
||||
logger.info("BrowserPool: created instance %d/%d", self._created, self._size)
|
||||
return browser
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Pre-create all instances up-front so first calls don't pay launch cost."""
|
||||
async with self._lock:
|
||||
for _ in range(self._size):
|
||||
b = await self._create()
|
||||
await self._queue.put(b)
|
||||
logger.info("BrowserPool: warmed up with %d instances", self._size)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Close all browsers. Safe to call multiple times."""
|
||||
self._closed = True
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
b = self._queue.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
try:
|
||||
await b.close()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("BrowserPool: close failed: %s", e)
|
||||
logger.info("BrowserPool: stopped")
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
"""Check out one browser for the duration of the `async with` block.
|
||||
|
||||
If the browser dies during use (any exception inside the block), we
|
||||
close it and replace with a fresh one on release.
|
||||
"""
|
||||
if self._closed:
|
||||
raise RuntimeError("Pool is closed")
|
||||
browser = await self._queue.get()
|
||||
broken = False
|
||||
try:
|
||||
yield browser
|
||||
except Exception:
|
||||
broken = True
|
||||
raise
|
||||
finally:
|
||||
if broken:
|
||||
try:
|
||||
await browser.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
fresh = await self._create()
|
||||
await self._queue.put(fresh)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("BrowserPool: failed to replace dead browser: %s", e)
|
||||
# Pool shrinks until next successful recreate
|
||||
else:
|
||||
await self._queue.put(browser)
|
||||
50
ai_platform/modules/cloak/src/cloak/config.py
Normal file
50
ai_platform/modules/cloak/src/cloak/config.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Cloak service configuration — env-driven via pydantic-settings."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class CloakSettings(BaseSettings):
|
||||
"""Runtime configuration for the cloak service."""
|
||||
|
||||
model_config = SettingsConfigDict(env_prefix="CLOAK_", case_sensitive=False)
|
||||
|
||||
# HTTP server
|
||||
host: str = "0.0.0.0"
|
||||
port: int = 8770
|
||||
|
||||
# Browser pool — number of CloakBrowser instances kept warm.
|
||||
# Each instance uses ~400 MB RAM. Default 3 keeps ~1.2 GB footprint
|
||||
# which fits comfortably alongside the other AI platform services.
|
||||
pool_size: int = 3
|
||||
|
||||
# Per-search timeout (entire search across engines).
|
||||
search_timeout_sec: int = 20
|
||||
|
||||
# Per-engine page load timeout.
|
||||
page_timeout_ms: int = 18000
|
||||
|
||||
# Max engines per call (cap to avoid abuse).
|
||||
max_engines: int = 3
|
||||
|
||||
# Max queries per call (cap to avoid abuse).
|
||||
max_queries: int = 5
|
||||
|
||||
# Default max results per (engine, query).
|
||||
default_max_results: int = 10
|
||||
max_results_cap: int = 30
|
||||
|
||||
# Throttling — minimum delay between successive scrapes on the same engine.
|
||||
# Helps avoid tripping rate-limits when called in bursts.
|
||||
engine_min_interval_ms: int = 200
|
||||
|
||||
# Humanize input (mouse/keyboard timing) — slows requests slightly but
|
||||
# improves bot-detection scores. Default off for bulk throughput.
|
||||
humanize: bool = False
|
||||
|
||||
# Optional shared bearer token. Empty = no auth (intra-cluster only).
|
||||
auth_token: str = ""
|
||||
|
||||
# Log level
|
||||
log_level: str = "INFO"
|
||||
81
ai_platform/modules/cloak/src/cloak/schemas.py
Normal file
81
ai_platform/modules/cloak/src/cloak/schemas.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
"""Pydantic schemas — request / response contracts for the cloak service."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
Engine = Literal["google", "bing", "ddg"]
|
||||
|
||||
|
||||
class SearchRequest(BaseModel):
|
||||
"""One search call across one or more engines."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
queries: list[str] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="One or more text queries; each is scraped on every engine in `engines`.",
|
||||
)
|
||||
engines: list[Engine] = Field(
|
||||
default=["google", "bing", "ddg"],
|
||||
description="Search engines to scrape. Order is independent (parallel execution).",
|
||||
)
|
||||
max_results_per_engine: int = Field(
|
||||
default=10,
|
||||
ge=1,
|
||||
description="Max organic results returned per (engine, query).",
|
||||
)
|
||||
language: str | None = Field(
|
||||
default=None,
|
||||
description="Preferred language hint (e.g. 'en', 'ro'). Engine-specific behavior.",
|
||||
)
|
||||
|
||||
|
||||
class SearchResult(BaseModel):
|
||||
"""One organic search result."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
url: str
|
||||
title: str
|
||||
snippet: str = ""
|
||||
engine: Engine
|
||||
query: str
|
||||
rank: int = Field(..., description="1-based rank within the engine's result list.")
|
||||
|
||||
|
||||
class EngineStats(BaseModel):
|
||||
"""Per-engine breakdown for diagnostics."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
engine: Engine
|
||||
query: str
|
||||
results_count: int
|
||||
blocked: bool = False
|
||||
captcha: bool = False
|
||||
elapsed_ms: int
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class SearchResponse(BaseModel):
|
||||
"""Aggregate response across all (engine × query) pairs."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
results: list[SearchResult]
|
||||
stats: list[EngineStats]
|
||||
total_elapsed_ms: int
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
"""Health-probe payload."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
status: Literal["healthy", "degraded", "unhealthy"]
|
||||
pool_size: int
|
||||
pool_available: int
|
||||
version: str
|
||||
254
ai_platform/modules/cloak/src/cloak/scraper.py
Normal file
254
ai_platform/modules/cloak/src/cloak/scraper.py
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
"""HTML scrapers for Google / Bing / DuckDuckGo search result pages.
|
||||
|
||||
Each scraper accepts a Playwright Page (CloakBrowser-backed) and returns a
|
||||
list of (url, title, snippet) tuples plus block/captcha flags. Selectors are
|
||||
intentionally redundant — Google in particular rotates result-DOM classes
|
||||
periodically. If both primary and fallback selectors fail, returns empty list.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import quote_plus, unquote, urlparse, parse_qs
|
||||
|
||||
logger = logging.getLogger("cloak.scraper")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScrapeOutput:
|
||||
results: list[tuple[str, str, str]] # (url, title, snippet)
|
||||
blocked: bool = False
|
||||
captcha: bool = False
|
||||
error: str | None = None
|
||||
|
||||
|
||||
# ─── URLs ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
def search_url(engine: str, query: str, language: str | None = None) -> str:
|
||||
q = quote_plus(query)
|
||||
if engine == "google":
|
||||
hl = language or "en"
|
||||
return f"https://www.google.com/search?q={q}&hl={hl}&num=20"
|
||||
if engine == "bing":
|
||||
return f"https://www.bing.com/search?q={q}&count=20"
|
||||
if engine == "ddg":
|
||||
# HTML endpoint is more scraper-friendly than the JS-driven SPA.
|
||||
return f"https://html.duckduckgo.com/html/?q={q}"
|
||||
raise ValueError(f"Unsupported engine: {engine}")
|
||||
|
||||
|
||||
# ─── Block / captcha detection ────────────────────────────────────────────────
|
||||
|
||||
def _detect_block(html: str, title: str) -> tuple[bool, bool]:
|
||||
"""Return (blocked, captcha) flags by inspecting page content."""
|
||||
t = title.lower()
|
||||
h = html.lower()
|
||||
captcha_markers = [
|
||||
"unusual traffic",
|
||||
"before you continue to google search",
|
||||
"/sorry/index",
|
||||
"recaptcha",
|
||||
"are you a robot",
|
||||
]
|
||||
block_markers = [
|
||||
"access denied",
|
||||
"<title>just a moment...</title>",
|
||||
"checking your browser",
|
||||
"blocked",
|
||||
]
|
||||
captcha = any(m in h for m in captcha_markers) or "sorry" in t
|
||||
blocked = False
|
||||
# Only flag block if we ALSO see no result containers (avoids false positives
|
||||
# on pages that legitimately mention "blocked" in editorial content).
|
||||
return blocked, captcha
|
||||
|
||||
|
||||
def _clean_google_redirect(href: str) -> str:
|
||||
"""Google sometimes wraps result URLs in /url?q=...&sa=...; strip it."""
|
||||
if href.startswith("/url?"):
|
||||
try:
|
||||
qs = parse_qs(urlparse("http://x" + href).query)
|
||||
target = qs.get("q", [None])[0]
|
||||
if target:
|
||||
return unquote(target)
|
||||
except Exception:
|
||||
pass
|
||||
return href
|
||||
|
||||
|
||||
# ─── Per-engine scrapers ──────────────────────────────────────────────────────
|
||||
|
||||
async def _scrape_google(page, max_results: int) -> ScrapeOutput:
|
||||
"""Google organic results. Multiple selector strategies for resilience.
|
||||
|
||||
Captcha detection happens AFTER extraction: if we got 0 results AND markers
|
||||
are present, it's a real captcha. The "before you continue" cookies banner
|
||||
and "/sorry/index" footer links are present on every normal Google SERP,
|
||||
so checking markers up front gives massive false-positives.
|
||||
"""
|
||||
selectors_to_try = [
|
||||
# Modern (2025-2026) class names
|
||||
"div.MjjYud:has(a h3)",
|
||||
# Legacy: any div containing an h3 inside a link
|
||||
"div.g a:has(h3)",
|
||||
# Last resort
|
||||
"a:has(h3)",
|
||||
]
|
||||
|
||||
title = await page.title()
|
||||
html = await page.content()
|
||||
|
||||
for sel in selectors_to_try:
|
||||
try:
|
||||
locator = page.locator(sel)
|
||||
count = await locator.count()
|
||||
if count == 0:
|
||||
continue
|
||||
results: list[tuple[str, str, str]] = []
|
||||
for i in range(min(count, max_results * 2)):
|
||||
el = locator.nth(i)
|
||||
try:
|
||||
# In modern Google each result has h3 inside an anchor.
|
||||
a = el if (await el.evaluate("e => e.tagName")) == "A" else el.locator("a:has(h3)").first
|
||||
href = await a.get_attribute("href")
|
||||
if not href:
|
||||
continue
|
||||
href = _clean_google_redirect(href)
|
||||
if not href.startswith("http"):
|
||||
continue
|
||||
try:
|
||||
h3 = a.locator("h3").first
|
||||
title_text = (await h3.inner_text()).strip()
|
||||
except Exception:
|
||||
title_text = (await a.inner_text()).strip()[:120]
|
||||
if not title_text:
|
||||
continue
|
||||
# Snippet — best-effort, optional
|
||||
snippet = ""
|
||||
try:
|
||||
# Look for any sibling or descendant span with text content.
|
||||
snippet = await el.evaluate(
|
||||
"e => { const t = e.innerText || ''; const lines = t.split('\\n'); return lines.slice(1, 4).join(' '); }"
|
||||
)
|
||||
snippet = (snippet or "").strip()[:300]
|
||||
except Exception:
|
||||
pass
|
||||
results.append((href, title_text, snippet))
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
if results:
|
||||
return ScrapeOutput(results=results)
|
||||
except Exception as e:
|
||||
logger.debug("Google selector '%s' failed: %s", sel, e)
|
||||
|
||||
# Zero results extracted → now check markers to distinguish captcha vs DOM rotation
|
||||
_, captcha = _detect_block(html, title)
|
||||
if captcha or "/sorry/index" in html or "unusual traffic" in html.lower():
|
||||
return ScrapeOutput(results=[], captcha=True, error="captcha_detected_after_zero_results")
|
||||
return ScrapeOutput(results=[], blocked=True, error="no_results_no_selectors_matched")
|
||||
|
||||
|
||||
async def _scrape_bing(page, max_results: int) -> ScrapeOutput:
|
||||
"""Bing organic results — `li.b_algo` is stable since ~2010.
|
||||
|
||||
Same captcha-after-extraction strategy as Google.
|
||||
"""
|
||||
title = await page.title()
|
||||
html = await page.content()
|
||||
|
||||
try:
|
||||
locator = page.locator("li.b_algo")
|
||||
count = await locator.count()
|
||||
results: list[tuple[str, str, str]] = []
|
||||
for i in range(min(count, max_results)):
|
||||
li = locator.nth(i)
|
||||
try:
|
||||
a = li.locator("h2 a").first
|
||||
href = await a.get_attribute("href")
|
||||
title_text = (await a.inner_text()).strip()
|
||||
snippet = ""
|
||||
try:
|
||||
snippet_locator = li.locator(".b_caption p, .b_lineclamp2, .b_paractl")
|
||||
if await snippet_locator.count() > 0:
|
||||
snippet = (await snippet_locator.first.inner_text()).strip()[:300]
|
||||
except Exception:
|
||||
pass
|
||||
if href and title_text:
|
||||
results.append((href, title_text, snippet))
|
||||
except Exception:
|
||||
continue
|
||||
if results:
|
||||
return ScrapeOutput(results=results)
|
||||
# Zero results — check captcha markers
|
||||
_, captcha = _detect_block(html, title)
|
||||
if captcha:
|
||||
return ScrapeOutput(results=[], captcha=True, error="captcha_after_zero_results")
|
||||
return ScrapeOutput(results=[], blocked=True, error="no_li_b_algo_or_empty")
|
||||
except Exception as e:
|
||||
return ScrapeOutput(results=[], blocked=True, error=f"bing_scrape_error: {e}")
|
||||
|
||||
|
||||
async def _scrape_ddg(page, max_results: int) -> ScrapeOutput:
|
||||
"""DuckDuckGo HTML endpoint (html.duckduckgo.com/html). Captcha-after-extraction."""
|
||||
title = await page.title()
|
||||
html = await page.content()
|
||||
|
||||
selectors_to_try = ["div.result", "div.web-result"]
|
||||
for sel in selectors_to_try:
|
||||
try:
|
||||
locator = page.locator(sel)
|
||||
count = await locator.count()
|
||||
if count == 0:
|
||||
continue
|
||||
results: list[tuple[str, str, str]] = []
|
||||
for i in range(min(count, max_results)):
|
||||
el = locator.nth(i)
|
||||
try:
|
||||
a = el.locator("a.result__a, h2 a").first
|
||||
href = await a.get_attribute("href")
|
||||
title_text = (await a.inner_text()).strip()
|
||||
# DDG html sometimes wraps href in a redirect; resolve.
|
||||
if href and "uddg=" in href:
|
||||
try:
|
||||
qs = parse_qs(urlparse(href).query)
|
||||
real = qs.get("uddg", [None])[0]
|
||||
if real:
|
||||
href = unquote(real)
|
||||
except Exception:
|
||||
pass
|
||||
snippet = ""
|
||||
try:
|
||||
s = el.locator(".result__snippet").first
|
||||
if await s.count() > 0:
|
||||
snippet = (await s.inner_text()).strip()[:300]
|
||||
except Exception:
|
||||
pass
|
||||
if href and title_text:
|
||||
results.append((href, title_text, snippet))
|
||||
except Exception:
|
||||
continue
|
||||
if results:
|
||||
return ScrapeOutput(results=results)
|
||||
except Exception as e:
|
||||
logger.debug("DDG selector '%s' failed: %s", sel, e)
|
||||
_, captcha = _detect_block(html, title)
|
||||
if captcha:
|
||||
return ScrapeOutput(results=[], captcha=True, error="captcha_after_zero_results")
|
||||
return ScrapeOutput(results=[], blocked=True, error="no_results_no_selectors_matched")
|
||||
|
||||
|
||||
_SCRAPERS = {
|
||||
"google": _scrape_google,
|
||||
"bing": _scrape_bing,
|
||||
"ddg": _scrape_ddg,
|
||||
}
|
||||
|
||||
|
||||
async def scrape(engine: str, page, max_results: int) -> ScrapeOutput:
|
||||
"""Dispatch to the engine-specific scraper."""
|
||||
fn = _SCRAPERS.get(engine)
|
||||
if not fn:
|
||||
return ScrapeOutput(results=[], error=f"unsupported_engine:{engine}")
|
||||
return await fn(page, max_results)
|
||||
208
ai_platform/modules/cloak/src/cloak/server.py
Normal file
208
ai_platform/modules/cloak/src/cloak/server.py
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
"""FastAPI server — POST /v1/search, GET /health."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from .browser_pool import BrowserPool
|
||||
from .config import CloakSettings
|
||||
from .schemas import (
|
||||
EngineStats,
|
||||
HealthResponse,
|
||||
SearchRequest,
|
||||
SearchResponse,
|
||||
SearchResult,
|
||||
)
|
||||
from .scraper import scrape, search_url
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("cloak.server")
|
||||
|
||||
|
||||
settings = CloakSettings()
|
||||
logger.setLevel(settings.log_level.upper())
|
||||
|
||||
pool: BrowserPool | None = None
|
||||
_engine_last_call: dict[str, float] = {}
|
||||
_engine_lock = asyncio.Lock()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Spin up the browser pool on startup, tear down on shutdown."""
|
||||
global pool
|
||||
pool = BrowserPool(size=settings.pool_size, humanize=settings.humanize)
|
||||
try:
|
||||
await pool.start()
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("Failed to warm browser pool: %s", e)
|
||||
# Continue running — pool may recover via lazy creation on next acquire
|
||||
logger.info("cloak service ready on port %d (pool=%d)", settings.port, settings.pool_size)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if pool:
|
||||
await pool.stop()
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="cloak",
|
||||
version="0.1.0",
|
||||
description="Stealth Chromium scraping service. Scrapes Google/Bing/DuckDuckGo SERPs.",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
def _require_auth(authorization: str | None = Header(default=None)) -> None:
|
||||
"""Optional bearer-token check (skipped when CLOAK_AUTH_TOKEN is empty)."""
|
||||
if not settings.auth_token:
|
||||
return
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token")
|
||||
if authorization.removeprefix("Bearer ").strip() != settings.auth_token:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Invalid token")
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthResponse, dependencies=[Depends(_require_auth)] if False else [])
|
||||
async def health() -> HealthResponse:
|
||||
p = pool
|
||||
if p is None:
|
||||
return HealthResponse(status="unhealthy", pool_size=0, pool_available=0, version="0.1.0")
|
||||
status_label = "healthy" if p.available > 0 else "degraded"
|
||||
return HealthResponse(
|
||||
status=status_label,
|
||||
pool_size=p.size,
|
||||
pool_available=p.available,
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
|
||||
async def _throttle(engine: str) -> None:
|
||||
"""Enforce a minimum interval between requests to the same engine."""
|
||||
if settings.engine_min_interval_ms <= 0:
|
||||
return
|
||||
async with _engine_lock:
|
||||
now = time.monotonic()
|
||||
last = _engine_last_call.get(engine, 0.0)
|
||||
gap = (now - last) * 1000
|
||||
wait = settings.engine_min_interval_ms - gap
|
||||
if wait > 0:
|
||||
await asyncio.sleep(wait / 1000)
|
||||
_engine_last_call[engine] = time.monotonic()
|
||||
|
||||
|
||||
async def _scrape_one(
|
||||
engine: str,
|
||||
query: str,
|
||||
max_results: int,
|
||||
language: str | None,
|
||||
) -> tuple[list[SearchResult], EngineStats]:
|
||||
"""Run one (engine, query) scrape and return parsed results + stats."""
|
||||
assert pool is not None
|
||||
t0 = time.monotonic()
|
||||
url = search_url(engine, query, language)
|
||||
out_results: list[SearchResult] = []
|
||||
stat = EngineStats(
|
||||
engine=engine, query=query, results_count=0,
|
||||
blocked=False, captcha=False, elapsed_ms=0, error=None,
|
||||
)
|
||||
try:
|
||||
await _throttle(engine)
|
||||
async with pool.acquire() as browser:
|
||||
page = await browser.new_page()
|
||||
try:
|
||||
await page.goto(url, timeout=settings.page_timeout_ms, wait_until="domcontentloaded")
|
||||
await page.wait_for_timeout(800) # let JS settle
|
||||
output = await scrape(engine, page, max_results)
|
||||
stat.blocked = output.blocked
|
||||
stat.captcha = output.captcha
|
||||
if output.error:
|
||||
stat.error = output.error
|
||||
for rank, (u, t, s) in enumerate(output.results, start=1):
|
||||
out_results.append(SearchResult(
|
||||
url=u, title=t, snippet=s,
|
||||
engine=engine, query=query, rank=rank,
|
||||
))
|
||||
finally:
|
||||
try:
|
||||
await page.close()
|
||||
except Exception:
|
||||
pass
|
||||
except asyncio.TimeoutError:
|
||||
stat.error = "timeout"
|
||||
stat.blocked = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
stat.error = f"{type(e).__name__}: {str(e)[:100]}"
|
||||
stat.blocked = True
|
||||
|
||||
stat.results_count = len(out_results)
|
||||
stat.elapsed_ms = int((time.monotonic() - t0) * 1000)
|
||||
return out_results, stat
|
||||
|
||||
|
||||
@app.post("/v1/search", response_model=SearchResponse, dependencies=[Depends(_require_auth)])
|
||||
async def search(req: SearchRequest, request: Request) -> SearchResponse:
|
||||
"""Run search across all (engine × query) pairs in parallel."""
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Pool not ready")
|
||||
|
||||
# Apply caps
|
||||
queries = req.queries[: settings.max_queries]
|
||||
engines = req.engines[: settings.max_engines]
|
||||
per_engine = min(req.max_results_per_engine, settings.max_results_cap)
|
||||
|
||||
t0 = time.monotonic()
|
||||
|
||||
async def _runner():
|
||||
tasks = [
|
||||
_scrape_one(e, q, per_engine, req.language)
|
||||
for e in engines for q in queries
|
||||
]
|
||||
return await asyncio.gather(*tasks, return_exceptions=False)
|
||||
|
||||
try:
|
||||
gathered = await asyncio.wait_for(_runner(), timeout=settings.search_timeout_sec)
|
||||
except asyncio.TimeoutError:
|
||||
raise HTTPException(status_code=504, detail=f"Search exceeded {settings.search_timeout_sec}s")
|
||||
|
||||
all_results: list[SearchResult] = []
|
||||
stats: list[EngineStats] = []
|
||||
for results, stat in gathered:
|
||||
all_results.extend(results)
|
||||
stats.append(stat)
|
||||
|
||||
elapsed = int((time.monotonic() - t0) * 1000)
|
||||
logger.info(
|
||||
"search: q=%d e=%d -> results=%d in %dms",
|
||||
len(queries), len(engines), len(all_results), elapsed,
|
||||
)
|
||||
return SearchResponse(results=all_results, stats=stats, total_elapsed_ms=elapsed)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def _generic(_: Request, exc: Exception): # noqa: ARG001
|
||||
logger.exception("Unhandled error")
|
||||
return JSONResponse(status_code=500, content={"error": str(exc)[:200]})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
import uvicorn
|
||||
|
||||
uvicorn.run(
|
||||
"cloak.server:app",
|
||||
host=settings.host,
|
||||
port=settings.port,
|
||||
log_level=settings.log_level.lower(),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
0
ai_platform/modules/cloak/tests/__init__.py
Normal file
0
ai_platform/modules/cloak/tests/__init__.py
Normal file
50
ai_platform/modules/cloak/tests/test_schemas.py
Normal file
50
ai_platform/modules/cloak/tests/test_schemas.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""Quick schema validation tests — run with `pytest tests/`."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from cloak.schemas import SearchRequest, SearchResponse, EngineStats, SearchResult
|
||||
|
||||
|
||||
def test_search_request_minimal():
|
||||
req = SearchRequest(queries=["BNR confiscare"])
|
||||
assert req.queries == ["BNR confiscare"]
|
||||
assert req.engines == ["google", "bing", "ddg"]
|
||||
assert req.max_results_per_engine == 10
|
||||
|
||||
|
||||
def test_search_request_rejects_unknown_engine():
|
||||
with pytest.raises(ValidationError):
|
||||
SearchRequest(queries=["x"], engines=["yahoo"])
|
||||
|
||||
|
||||
def test_search_request_rejects_empty_queries():
|
||||
with pytest.raises(ValidationError):
|
||||
SearchRequest(queries=[])
|
||||
|
||||
|
||||
def test_search_response_round_trip():
|
||||
resp = SearchResponse(
|
||||
results=[
|
||||
SearchResult(
|
||||
url="https://example.com",
|
||||
title="Example",
|
||||
snippet="...",
|
||||
engine="google",
|
||||
query="test",
|
||||
rank=1,
|
||||
),
|
||||
],
|
||||
stats=[
|
||||
EngineStats(
|
||||
engine="google", query="test",
|
||||
results_count=1, elapsed_ms=2000,
|
||||
),
|
||||
],
|
||||
total_elapsed_ms=2050,
|
||||
)
|
||||
j = resp.model_dump_json()
|
||||
parsed = SearchResponse.model_validate_json(j)
|
||||
assert len(parsed.results) == 1
|
||||
assert parsed.stats[0].engine == "google"
|
||||
9
ai_platform/modules/dashboard/.dockerignore
Normal file
9
ai_platform/modules/dashboard/.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/.venv
|
||||
.git
|
||||
.pytest_cache
|
||||
|
||||
# Don't copy node deps into build context — Dockerfile installs fresh
|
||||
web/node_modules
|
||||
web/dist
|
||||
83
ai_platform/modules/dashboard/.env.example
Normal file
83
ai_platform/modules/dashboard/.env.example
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# =============================================================================
|
||||
# Dashboard Module — Environment Configuration
|
||||
# =============================================================================
|
||||
# Copy this file to .env and fill in the values marked with CHANGE_ME.
|
||||
# All variables use the DASHBOARD_ prefix.
|
||||
#
|
||||
# Most dashboard settings mirror what web-api uses — the dashboard talks
|
||||
# to the same provider APIs to display live quota + billing info.
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# REQUIRED — PostgreSQL
|
||||
# -----------------------------------------------------------------------------
|
||||
# These are used by both the postgres container and the dashboard app.
|
||||
# Change the password before deploying to a shared environment.
|
||||
DASHBOARD_DB_USER=dashboard
|
||||
DASHBOARD_DB_PASSWORD=CHANGE_ME_secure_password
|
||||
DASHBOARD_DB_NAME=dashboard
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# REQUIRED — External URL for OpenAPI spec
|
||||
# -----------------------------------------------------------------------------
|
||||
DASHBOARD_EXTERNAL_URL=http://localhost:51300
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Provider API keys (for live quota / billing pages)
|
||||
# -----------------------------------------------------------------------------
|
||||
# These are read-only — the dashboard queries each provider's /account
|
||||
# endpoint to show how many credits are left. Leave blank to disable
|
||||
# that provider's card.
|
||||
# DASHBOARD_SERPAPI_API_KEY=CHANGE_ME_or_leave_blank
|
||||
# DASHBOARD_TAVILY_API_KEY=CHANGE_ME_or_leave_blank
|
||||
# DASHBOARD_BRAVE_API_KEY=CHANGE_ME_or_leave_blank
|
||||
# DASHBOARD_LINKUP_API_KEY=CHANGE_ME_or_leave_blank
|
||||
# DASHBOARD_EXA_API_KEY=CHANGE_ME_or_leave_blank
|
||||
# DASHBOARD_OPENROUTER_API_KEY=CHANGE_ME_sk-or-v1-...
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Upstream service URLs for health checks
|
||||
# -----------------------------------------------------------------------------
|
||||
# The dashboard probes these to show their status on /providers page.
|
||||
# Point the LLM URLs at your GPU machine (no GPU on this host).
|
||||
DASHBOARD_WEB_API_URL=http://didiAI-web-api:51100
|
||||
DASHBOARD_SEARXNG_URL=http://didiAI-web-searxng:8080
|
||||
DASHBOARD_LLM_API_URL=CHANGE_ME_http://your-gpu-host:14011
|
||||
DASHBOARD_VLLM_QWEN_URL=CHANGE_ME_http://your-gpu-host:14001
|
||||
# Comma-separated list of llama.cpp / vLLM servers (load balanced)
|
||||
# DASHBOARD_LLAMACPP_URLS=http://10.11.10.18:14001,http://10.11.10.19:14001
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Retention & caching
|
||||
# -----------------------------------------------------------------------------
|
||||
# How many days of request_history to keep (older rows are purged hourly).
|
||||
DASHBOARD_HISTORY_RETENTION_DAYS=30
|
||||
# How long to cache provider quota responses (seconds).
|
||||
DASHBOARD_PROVIDER_STATS_CACHE_SECONDS=30
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Authentication
|
||||
# -----------------------------------------------------------------------------
|
||||
# Legacy static tokens — leave blank and use DB-backed users instead:
|
||||
# docker exec didiAI-dashboard python -m dashboard.cli create-user <name>
|
||||
# DASHBOARD_API_TOKENS=
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Server settings
|
||||
# -----------------------------------------------------------------------------
|
||||
# DASHBOARD_HOST=0.0.0.0
|
||||
# DASHBOARD_PORT=51300
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OPTIONAL — Logging
|
||||
# -----------------------------------------------------------------------------
|
||||
# DASHBOARD_LOG_LEVEL=INFO
|
||||
# DASHBOARD_LOG_JSON=false
|
||||
274
ai_platform/modules/dashboard/INDEX.md
Normal file
274
ai_platform/modules/dashboard/INDEX.md
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
# Dashboard Module - INDEX
|
||||
|
||||
AI platform monitoring/admin dashboard. Browse archived claims, view ingest history, audit trail, runtime config overrides, costs per provider/tier, and live provider quotas. Single FastAPI service serving:
|
||||
1. **React 19 + MUI 7 SPA** at `/admin-ai/` (default modern UI, added in Phase C 2026-05-02)
|
||||
2. **Jinja2 templates** at `/`, `/history`, `/cost`, `/providers`, `/archive`, `/audit`, `/config` (legacy, kept side-by-side until full deprecation)
|
||||
3. **JSON API** at `/api/*` and `/admin-ai/api/*` (dual-mounted) consumed by `web-api` + the SPA itself
|
||||
|
||||
## Stack
|
||||
|
||||
- Python 3.10+ (FastAPI 0.115+, Uvicorn)
|
||||
- React 19 + MUI 7 + Vite 7 + react-router 7 + TanStack Query + Recharts (built into `/app/web_dist/`, served as static via SPAStaticFiles with index-fallback)
|
||||
- Jinja2 templates legacy (HTMX + Alpine.js + Tailwind CDN — kept until React reaches 100% parity)
|
||||
- SQLAlchemy 2.0 async + asyncpg, Alembic
|
||||
- pydantic-settings (env prefix `DASHBOARD_`)
|
||||
- httpx for live provider quota fetching, brain proxy
|
||||
- python-jose[cryptography] for Keycloak JWT validation
|
||||
|
||||
## Coordinates
|
||||
|
||||
- **SPA URL**: `https://10.11.10.12:8443/admin-ai/` (via frontend nginx) or `http://10.11.10.12:51300/admin-ai/` (direct)
|
||||
- **Public URL**: `https://didi365.eu/admin-ai/` (via Cloudflare tunnel + frontend nginx)
|
||||
- Container: `didiAI-dashboard` (alongside `didiAI-dashboard-db` on `:15432`)
|
||||
- Compose: `deploy/docker-compose.yml`, profile `dashboard`
|
||||
- Network: `didi-network` (unified single network for all DIDI + AI platform stacks since 2026-05-04)
|
||||
|
||||
## Auth (current state — 2026-05-04)
|
||||
|
||||
**Hybrid auth** in `dependencies.py:verify_bearer_token`:
|
||||
1. `STAGING_MODE=true` → all auth bypassed (default for dev)
|
||||
2. JWT (3 dot-separated parts) → validated via `keycloak_auth.py` (JWKS cache 10min, signature, issuer, exp, role check)
|
||||
3. DB-backed bearer tokens (legacy, from CLI `dashboard create-user`)
|
||||
4. Static `api_tokens` env var (legacy fallback)
|
||||
5. Otherwise → 401
|
||||
|
||||
**Keycloak settings** (`config.py:DashboardSettings`):
|
||||
- `keycloak_url`, `keycloak_realm` (default `didi-clients`), `keycloak_client_id` (default `ai-platform-dashboard`)
|
||||
- `keycloak_required_role` (default `admin` — same role as DIDI admin-dashboard for unified access)
|
||||
- Manual setup: create client + assign role via Keycloak admin or `deploy/setup-keycloak.sh`
|
||||
|
||||
**Cutover from staging to prod**:
|
||||
- Set `DASHBOARD_KEYCLOAK_URL=https://sso.clossers.com`, `DASHBOARD_STAGING_MODE=false`, `VITE_STAGING_MODE=false`
|
||||
- Rebuild image with build args (Dockerfile bakes Keycloak config into JS bundle at build time)
|
||||
|
||||
Plan/runbook: `AI_PLATFORM_RESKIN_PLAN.md` (Phase C.8) + `/home/admin365/didi_mono/UNIFIED_KEYCLOAK_CUTOVER.md`
|
||||
|
||||
## Ce face
|
||||
|
||||
Sections served as HTML pages (`pages.py`) + JSON-mirror endpoints in `routes/`:
|
||||
|
||||
- **Overview** (`/`) - KPIs (totals, error rate, avg duration, total cost) + provider grid (live quotas)
|
||||
- **Providers** (`/providers`) - detailed provider cards + raw table (live SerpAPI/Tavily/Brave/LinkUp/Exa/OpenRouter/internal stats)
|
||||
- **History** (`/history`, `/history/{request_id}`) - request log with filters (tier, provider, endpoint, hours) + drill-down with full stages + raw_request/response
|
||||
- **Cost** (`/cost`) - 24h/7d/30d spend, projected monthly, by-provider, by-tier, top 10 expensive requests, quota-vs-budget bars
|
||||
- **Archive** (`/archive`, `/archive/{claim_id}`) - browse promoted claims + linked articles (permanent storage seeded via `POST /archive/promote/{request_id}`)
|
||||
- **Audit** (`/audit`) - audit log entries (config.set, config.reset, config.delete, archive.promote)
|
||||
- **Config** (`/config`) - runtime overrides table grouped by category (providers/routing/llm/tiers); HTMX in-place edit + reset
|
||||
|
||||
## API endpoints
|
||||
|
||||
### `routes/health.py`
|
||||
- `GET /health` - liveness + DB ping
|
||||
- `GET /ready` - app.state populated check
|
||||
|
||||
### `routes/ingest.py` (no auth, service-to-service)
|
||||
- `POST /api/ingest/event` - receives request events from `web-api` middleware; trims fields, computes cost via `pricing.estimate_cost` if missing, inserts into `request_history`
|
||||
|
||||
### `routes/history.py` (no auth, read-only)
|
||||
- `GET /api/history` - filtered list (tier, provider, endpoint, hours, limit, offset)
|
||||
- `GET /api/history/{request_id}` - full record (includes stages + raw_request/response)
|
||||
|
||||
### `routes/stats.py` (no auth, read-only)
|
||||
- `GET /api/stats/providers?force=` - live provider stats (cached `provider_stats_cache_seconds`, default 30s)
|
||||
- `GET /api/stats/summary?hours=` - aggregated counters (totals, by_tier, by_provider, by_endpoint, error_rate, avg_duration, total_cost)
|
||||
- `GET /api/stats/timeline?hours=` - hourly buckets (`date_trunc('hour', ...)`) for charts
|
||||
|
||||
### `routes/archive.py`
|
||||
- `GET /api/archive/claims` - paginated claims list with optional `q` ilike search
|
||||
- `GET /api/archive/claims/{claim_id}` - single claim + linked articles
|
||||
- `POST /api/archive/promote/{request_id}` (auth) - promote a `request_history` row into `claims_archive` + `articles_archive` + `claim_articles`; only `/v1/gather` rows can be promoted
|
||||
|
||||
### `routes/config.py`
|
||||
- `GET /api/config` - all keys with overrides merged on top of `KNOWN_KEYS` defaults (consumed by `web-api`, no auth)
|
||||
- `GET /api/config/{key}` - single key
|
||||
- `PUT /api/config/{key}` (auth) - set override, validates against schema (bool/int/enum/csv/string), writes audit log
|
||||
- `DELETE /api/config/{key}` (auth) - revert to default, writes audit log
|
||||
|
||||
`KNOWN_KEYS` in `routes/config.py` enumerates the runtime keys consumed by `web-api`: `web.providers.{serpapi,tavily,brave,linkup,exa}.enabled`, `web.premium.strategy`, `web.premium.priority_order`, `web.openrouter.model`, `web.tier.{free,premium}.max_search_results`.
|
||||
|
||||
### `routes/pages.py` (Jinja HTML)
|
||||
- `GET /` - overview
|
||||
- `GET /history`, `GET /history/{request_id}`
|
||||
- `GET /providers`
|
||||
- `GET /cost`
|
||||
- `GET /archive`, `GET /archive/{claim_id}`
|
||||
- `GET /audit`
|
||||
- `GET /config` + HTMX form handlers `POST /config/{key}` and `POST /config/{key}/reset` (return partial fragments)
|
||||
|
||||
## Structura fisiere
|
||||
|
||||
```
|
||||
src/dashboard/
|
||||
__init__.py
|
||||
cli.py # admin CLI: create-user, list-users, delete-user (python -m dashboard.cli)
|
||||
auth.py # SHA-256 + hmac.compare_digest, User CRUD
|
||||
config.py # DashboardSettings (pydantic-settings, DASHBOARD_ prefix), SettingsCache
|
||||
logging.py # get_logger helper (JSON or text via DASHBOARD_LOG_JSON)
|
||||
pricing.py # estimate_cost(endpoint, tier, provider) - per-provider USD
|
||||
retention.py # 30-day rolling cleanup of request_history
|
||||
api/
|
||||
app.py # FastAPI factory, lifespan (init engine + provider registry), router wiring
|
||||
dependencies.py # get_session, get_registry, verify_bearer_token, get_username
|
||||
routes/
|
||||
archive.py # claims archive CRUD + promote
|
||||
config.py # KNOWN_KEYS + runtime override CRUD
|
||||
health.py # /health, /ready
|
||||
history.py # /api/history (read-only)
|
||||
ingest.py # POST /api/ingest/event (service-to-service)
|
||||
pages.py # all Jinja HTML pages + HTMX handlers
|
||||
stats.py # /api/stats/{providers,summary,timeline}
|
||||
db/
|
||||
models.py # Base, RequestHistory, ProviderStatsHourly, ConfigOverride, User, AuditLog,
|
||||
# ClaimsArchive, ArticlesArchive, ClaimArticle
|
||||
session.py # init_engine, get_session, get_session_factory, close_engine
|
||||
providers/
|
||||
base.py # ProviderStats dataclass + ProviderClient ABC
|
||||
registry.py # ProviderRegistry (cached fan-out across providers)
|
||||
serpapi.py # SerpAPI quota + plan price
|
||||
tavily.py # Tavily quota
|
||||
brave.py # Brave Search quota
|
||||
linkup.py # LinkUp quota
|
||||
openrouter.py # OpenRouter spend
|
||||
internal.py # Internal services (web-api, SearXNG, vLLM, llama.cpp) live health
|
||||
templates/ # Jinja
|
||||
base.html
|
||||
overview.html
|
||||
archive.html, archive_detail.html
|
||||
audit.html
|
||||
config.html
|
||||
cost.html
|
||||
history.html, history_detail.html
|
||||
providers.html
|
||||
partials/
|
||||
config_row.html # HTMX swap target after edit/reset
|
||||
provider_card.html # reusable card on overview + providers pages
|
||||
quota_bar.html # quota progress bar
|
||||
static/
|
||||
css/, js/ # tailwind via CDN, htmx + alpine inline
|
||||
deploy/
|
||||
Dockerfile
|
||||
docker-compose.yml # didiAI-dashboard + didiAI-dashboard-db (postgres:16-alpine)
|
||||
deploy.sh # convenience wrapper around `docker compose --profile dashboard`
|
||||
tests/
|
||||
...
|
||||
pyproject.toml # hatchling build, ruff inherited from ../../ruff.toml
|
||||
uv.lock
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
`didiAI-dashboard-db` (postgres:16-alpine, host `:15432` -> container `:5432`). Tables:
|
||||
|
||||
- `request_history` - 30-day rolling per-request log (BigInt id, request_id unique, JSON stages/raw_request/raw_response, cost_usd Numeric(12,6), indexes on created_at, tier, provider, endpoint)
|
||||
- `provider_stats_hourly` - rollups by (provider, hour)
|
||||
- `config_overrides` - runtime config k/v overrides (key PK, JSON value, updated_by)
|
||||
- `users` - dashboard users (username unique, token_hash SHA-256, role, last_login)
|
||||
- `audit_log` - mutation history (timestamp, username, action, target, old_value/new_value JSON)
|
||||
- `claims_archive` - permanent claims storage (claim_hash unique, verdict, confidence Numeric(5,4), summary, entities JSON, tags JSON)
|
||||
- `articles_archive` - permanent article full-text (url unique, url_hash unique, full_text, publisher, credibility_score)
|
||||
- `claim_articles` - M2M claims <-> articles (relevance_score, snippet)
|
||||
|
||||
Connection string format: `postgresql+asyncpg://USER:PASS@didiAI-dashboard-db:5432/DB` injected via `DASHBOARD_DATABASE_URL`.
|
||||
|
||||
## Pages
|
||||
|
||||
- `base.html` - layout shell (Tailwind CDN, sidebar nav, HTMX + Alpine includes)
|
||||
- `overview.html` - KPI tiles + provider grid (uses `partials/provider_card.html` + `partials/quota_bar.html`)
|
||||
- `providers.html` - full provider cards + raw quota table
|
||||
- `history.html` / `history_detail.html` - filterable list + drill-down with stages JSON pretty-print
|
||||
- `cost.html` - cost cards + by-provider/by-tier breakdown + budget bars
|
||||
- `archive.html` / `archive_detail.html` - claim search + linked articles
|
||||
- `audit.html` - chronological mutation log
|
||||
- `config.html` - runtime overrides grouped by category, HTMX inline edit -> `partials/config_row.html`
|
||||
|
||||
## Authentication (current state)
|
||||
|
||||
- Bearer token in `Authorization: Bearer <token>` header
|
||||
- `dependencies.verify_bearer_token` reads header, hashes, scans `users`, returns `User` or 401
|
||||
- `dependencies.get_username(principal)` extracts username for audit log
|
||||
- Token issuance via CLI inside container:
|
||||
```
|
||||
docker exec -it didiAI-dashboard python -m dashboard.cli create-user <name> [--email] [--role admin|viewer]
|
||||
```
|
||||
- All read endpoints + `POST /api/ingest/event` are auth-free (VPN-internal trust)
|
||||
|
||||
## Reskin plan (FUTURE - NOT done yet)
|
||||
|
||||
- React 19 + MUI 7 + Keycloak SSO **DONE 2026-05-02** (Phase C in `agent-v3/IMPLEMENTATION_PLAN_HIL_BRAIN.md`, detail in `AI_PLATFORM_RESKIN_PLAN.md`)
|
||||
- Becomes admin-only (Keycloak realm role)
|
||||
- Bearer-token table retired; existing `role` column may persist for historical audit-log mapping
|
||||
- Brain admin UI (atom browse, force-gold, brain stats) added as a new section in this dashboard during Phase C
|
||||
- Jinja templates + HTMX endpoints in `pages.py` will be replaced by JSON endpoints; existing `routes/*.py` JSON API stays as the contract
|
||||
|
||||
## Deployment
|
||||
|
||||
```bash
|
||||
cd /home/admin365/didi_mono/ai_platform/modules/dashboard/deploy
|
||||
cp ../.env.example .env # set DASHBOARD_DB_USER/PASSWORD/NAME + provider keys
|
||||
./deploy.sh up # docker compose --profile dashboard up -d --build
|
||||
```
|
||||
|
||||
- Healthcheck: `python -c urllib.request.urlopen('http://localhost:51300/health')` every 30s
|
||||
- Restart policy: `unless-stopped`
|
||||
- Settings prefix: `DASHBOARD_*` (see `config.py` for full list)
|
||||
|
||||
## Ce NU face
|
||||
|
||||
- ~~No SSO yet~~ Keycloak SSO wired (DONE 2026-05-02). Bearer tokens kept as legacy fallback.
|
||||
- No multi-tenant - single shared `users` table, no per-tenant scoping
|
||||
- No public access - binds to internal `didi-network` network, not exposed via Kong/edge
|
||||
- ~~No React frontend yet~~ React 19 SPA at `/admin-ai/` (DONE 2026-05-02). Jinja kept side-by-side until parity.
|
||||
- No real-time push - HTMX polling, no WebSockets/SSE
|
||||
|
||||
## Related docs
|
||||
|
||||
- AI platform CLAUDE.md: `/home/admin365/didi_mono/ai_platform/CLAUDE.md`
|
||||
- Module README: `/home/admin365/didi_mono/ai_platform/modules/dashboard/README.md`
|
||||
- Reskin + Brain admin plan (Phase C): `/home/admin365/didi_mono/backend/services/orchestration-layer/agent-v3/IMPLEMENTATION_PLAN_HIL_BRAIN.md`
|
||||
- Web-api ingest middleware (the producer for `POST /api/ingest/event`): `/home/admin365/didi_mono/ai_platform/modules/web-api/`
|
||||
|
||||
## Recent Changes (2026-05-05)
|
||||
|
||||
- **3 pagini noi**:
|
||||
- `/admin-ai/operations/live` (Live Status) — KPI 1h cu refresh 5s, Recent Activity feed (latest 20 cu chip warning daca >1h vechi), Provider Health cu cache age, throughput sparkline 6h
|
||||
- `/admin-ai/system/settings` (System Settings) — health endpoint, identity card cu roluri, Configuration Surface (98 chei + breakdown per modul + count overrides), Quick Access dynamic links
|
||||
- `/admin-ai/system/schema` (Schema Overrides) — CRUD UI pentru `config_schema_override` (Register/Edit/Delete via dialog cu validare type/min/max/options)
|
||||
- **Pagini eliminate**: `/admin-ai/system/users` (admin backend gestioneaza userii Keycloak)
|
||||
- **Overview**: card Phase C status chips -> inlocuit cu Quick Links chips clickable (Live Status, History, Cost, Providers, Brain Atoms, Audit Log, Schema Overrides)
|
||||
- **Modules ModulePage** (toate 8): tab "Live State" functional cu real backend `/api/proxy/{moduleId}/health` (status badge, latency, payload `/v1/info`, refresh 10s, env override DASHBOARD_<MOD>_HEALTH_URL); tab "Actions" reformat ca "Pending Restarts" cu lista overrides cu `restart_required=true` + comanda SSH copy-able
|
||||
- **Backend endpoint nou**: `routes/proxy.py` cu `GET /api/proxy/{module_id}/health` — proxy catre modul real, hardcoded URLs pe 10.11.10.17 cu fallback la `DASHBOARD_<MODULE>_HEALTH_URL` env override
|
||||
- **Cost endpoint imbunatatit**: `GET /api/stats/cost` returneaza `projection_basis` (blend 30d=40%/7d=60%, sau 7d_avg, sau 24h_only), `projection_confidence` (stable/moderate/rough), `trend` (increasing/decreasing/stable), `trend_pct`. UI Cost page afiseaza confidence chip + trend arrow (↗↘→).
|
||||
- **Providers endpoint imbunatatit**: `GET /api/stats/providers` adauga `last_refresh` (ISO), `age_seconds`, `cache_ttl_seconds`. UI Providers + Live Status afiseaza "refreshed Xs ago" chip (warning peste 120s).
|
||||
- **ProviderRegistry**: tracks `_cache_wall: datetime` separat de `_cache_time` (monotonic), expune properties `last_refresh_iso` + `age_seconds`.
|
||||
- **Frontend type fix**: `Live.tsx` + `Providers.tsx` foloseau campuri inexistente (`status`, `quota_used_pct`, `plan`) — corectate la `healthy: bool`, `quota_percent_used`, `display_name`, `plan_name` (matching backend response).
|
||||
- **Schema migration**: 98 chei seed la primul startup in `config_schema_override`, vizibile in Schema Overrides UI cu actiuni Edit/Delete.
|
||||
|
||||
---
|
||||
|
||||
## Brain admin pages — Phase D2 (2026-05-05)
|
||||
|
||||
3 rute noi sub `/admin-ai/brain/` + 1 tab nou pe `system/audit`. Toate consumă brain prin proxy-ul `/api/brain/*`.
|
||||
|
||||
### `pages/brain/Facts.tsx`
|
||||
|
||||
Browser pe `brain_fact_status` cu filter (entity ILIKE, predicate exact, current_truth, locked_only, topic), DataGrid paginat. Click pe rând → drawer dreapta cu Triple summary (chip volatility + lock + topics), **Truth timeline** Stepper cu toate `brain_fact_version` rows, **Moderator override form** (moderator_user_id + set_truth + confidence + evidence URLs + lock/unlock + notes → PATCH `/v1/fact_status/{id}`).
|
||||
|
||||
### `pages/brain/Invalidate.tsx`
|
||||
|
||||
Form-driven mass invalidation cu UX 2-step: Build filter (topic_codes, entity_canonicals, claim_pattern, since, invalidate_gold cu warning) → **Preview (dry run)** → **Confirm invalidate** (button enabled doar după Preview). Side panel: ultimele 10 invalidări (auto-refresh 30s).
|
||||
|
||||
### `pages/system/AuditLog.tsx` — refactor cu tabs
|
||||
|
||||
Wrapper cu Tabs: **Dashboard tab** → `AuditLogDashboard.tsx` (existing code extras intact); **Brain tab** → `AuditLogBrain.tsx` nou, citește `/api/brain/v1/cache/audit_log` cu action presets (judge_*, fact_truth_*, invalidate, promote_gold), action chip color-coded, payload tooltip JSON pretty.
|
||||
|
||||
### Backend (`brain_proxy.py`)
|
||||
|
||||
Whitelist extins cu: `/v1/fact_status/list`, `/v1/fact_status/`, `/v1/cache/audit_log`, `/v1/cache/invalidate`, `/v1/canonicalize`. GET pass-through; POST/PATCH cer bearer.
|
||||
|
||||
### `types/brain.ts` extins
|
||||
|
||||
`Volatility`, `FactStatusItem(+List+VersionItem+Versions+Patch)Response`, `AuditLogItem(+Response)`, `CacheInvalidate(Request|Response)`.
|
||||
|
||||
### Routes + sidebar
|
||||
|
||||
`App.tsx`: `brain/facts`, `brain/invalidate`. `AppShell.tsx` Brain Admin section: Fact Status (FactCheckIcon), Invalidate (DeleteSweepIcon).
|
||||
60
ai_platform/modules/dashboard/README.md
Normal file
60
ai_platform/modules/dashboard/README.md
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Dashboard
|
||||
|
||||
Admin dashboard for the didiAI platform. Tracks search provider usage, costs, request history, and exposes runtime configuration.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Live quota & billing** — pulls real-time data from SerpAPI, Tavily, Brave, OpenRouter
|
||||
- **Health monitoring** — SearXNG, web-api, vLLM, llama.cpp servers
|
||||
- **Request history** — 30-day rolling log of every gather/search/fetch request with drill-down
|
||||
- **Cost tracking** — per-provider spend, projections, cost per tier
|
||||
- **Future:** runtime config (toggle providers, change strategies, manage tier caps)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker 24+ with Compose V2
|
||||
- PostgreSQL 16 (provided by compose)
|
||||
- Internal network access to web-api, SearXNG, LLM servers
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
cp ../.env.example .env # edit with your secrets
|
||||
./deploy.sh up
|
||||
```
|
||||
|
||||
Dashboard is now running at http://localhost:51300
|
||||
|
||||
## Endpoints
|
||||
|
||||
### Web UI
|
||||
- `/` — overview with KPIs and provider grid
|
||||
- `/providers` — detailed provider cards + raw table
|
||||
- `/history` — filterable request history
|
||||
- `/history/{request_id}` — full request detail with stages
|
||||
|
||||
### JSON API
|
||||
- `GET /health` — liveness
|
||||
- `GET /api/stats/providers` — live provider stats
|
||||
- `GET /api/stats/summary?hours=24` — aggregated counters
|
||||
- `GET /api/stats/timeline?hours=24` — hourly buckets for charts
|
||||
- `GET /api/history?limit=50&tier=premium` — filtered history
|
||||
- `GET /api/history/{request_id}` — single request with full payload
|
||||
- `POST /api/ingest/event` — receives events from web-api middleware
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
web-api ────► POST /api/ingest/event ────► dashboard-api ────► PostgreSQL
|
||||
│
|
||||
├─ reads provider APIs live
|
||||
└─ serves UI via Jinja2+HTMX
|
||||
```
|
||||
|
||||
## Tech stack
|
||||
|
||||
- FastAPI + Pydantic
|
||||
- SQLAlchemy 2.0 async + asyncpg
|
||||
- Jinja2 + HTMX + Alpine.js + Tailwind (zero build step)
|
||||
- PostgreSQL 16
|
||||
67
ai_platform/modules/dashboard/deploy/Dockerfile
Normal file
67
ai_platform/modules/dashboard/deploy/Dockerfile
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# ---- Stage 1: build React SPA ----
|
||||
FROM node:20-alpine AS web-builder
|
||||
WORKDIR /web
|
||||
|
||||
# Build-time SPA config — pass via `--build-arg VITE_*=...`. Defaults
|
||||
# point at the DIDI SSO cluster, which is what production uses.
|
||||
ARG VITE_KEYCLOAK_URL=https://sso.clossers.com
|
||||
ARG VITE_KEYCLOAK_REALM=didi-clients
|
||||
ARG VITE_KEYCLOAK_CLIENT_ID=ai-platform-dashboard
|
||||
ARG VITE_KEYCLOAK_REQUIRED_ROLE=admin
|
||||
ARG VITE_STAGING_MODE=false
|
||||
|
||||
ENV VITE_KEYCLOAK_URL=$VITE_KEYCLOAK_URL \
|
||||
VITE_KEYCLOAK_REALM=$VITE_KEYCLOAK_REALM \
|
||||
VITE_KEYCLOAK_CLIENT_ID=$VITE_KEYCLOAK_CLIENT_ID \
|
||||
VITE_KEYCLOAK_REQUIRED_ROLE=$VITE_KEYCLOAK_REQUIRED_ROLE \
|
||||
VITE_STAGING_MODE=$VITE_STAGING_MODE
|
||||
|
||||
# Cache deps
|
||||
COPY web/package.json web/package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
# Build
|
||||
COPY web/index.html web/tsconfig.json web/vite.config.ts ./
|
||||
COPY web/src ./src
|
||||
RUN npm run build
|
||||
|
||||
# ---- Stage 2: build Python deps ----
|
||||
FROM python:3.11.12-slim AS builder
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10 /uv /uvx /bin/
|
||||
|
||||
ENV UV_LINK_MODE=copy \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_PROJECT_ENVIRONMENT=/app/.venv
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
RUN uv venv /app/.venv && uv pip install --python /app/.venv/bin/python \
|
||||
fastapi uvicorn[standard] pydantic pydantic-settings httpx \
|
||||
"sqlalchemy[asyncio]>=2.0.36" asyncpg alembic jinja2 python-multipart
|
||||
|
||||
COPY src ./src
|
||||
|
||||
RUN uv pip install --python /app/.venv/bin/python -e .
|
||||
|
||||
# ---- Stage 3: runtime ----
|
||||
FROM python:3.11.12-slim
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=builder /app /app
|
||||
COPY --from=web-builder /web/dist /app/web_dist
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONPATH=/app/src \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 51300
|
||||
|
||||
CMD ["uvicorn", "dashboard.api.app:app", "--host", "0.0.0.0", "--port", "51300"]
|
||||
50
ai_platform/modules/dashboard/deploy/deploy.sh
Normal file
50
ai_platform/modules/dashboard/deploy/deploy.sh
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
[[ -f .env ]] && set -a && source .env && set +a
|
||||
|
||||
check_required_var() {
|
||||
if [[ -z "${!1:-}" ]]; then
|
||||
echo "ERROR: Required variable $1 not set (check .env)"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: ./deploy.sh {up|down|logs|restart}
|
||||
|
||||
Actions:
|
||||
up Build and start dashboard + database
|
||||
down Stop containers (volumes preserved)
|
||||
logs Tail logs
|
||||
restart Restart dashboard without rebuild
|
||||
EOF
|
||||
}
|
||||
|
||||
check_required_var DASHBOARD_DB_USER
|
||||
check_required_var DASHBOARD_DB_PASSWORD
|
||||
check_required_var DASHBOARD_DB_NAME
|
||||
check_required_var DASHBOARD_EXTERNAL_URL
|
||||
|
||||
case "${1:-}" in
|
||||
up)
|
||||
docker compose --profile dashboard up -d --build
|
||||
echo "Dashboard started at ${DASHBOARD_EXTERNAL_URL}"
|
||||
;;
|
||||
down)
|
||||
docker compose --profile dashboard down
|
||||
;;
|
||||
logs)
|
||||
docker compose --profile dashboard logs -f --tail=100
|
||||
;;
|
||||
restart)
|
||||
docker compose --profile dashboard restart
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
102
ai_platform/modules/dashboard/deploy/docker-compose.yml
Normal file
102
ai_platform/modules/dashboard/deploy/docker-compose.yml
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# Dashboard Module - Docker Compose
|
||||
#
|
||||
# Port Allocation (Dev: 51300):
|
||||
# 51300 - Dashboard API + Web UI
|
||||
# 15432 - PostgreSQL (optional host exposure for debugging)
|
||||
#
|
||||
# Naming Convention: didiAI-{module}-{service}
|
||||
|
||||
networks:
|
||||
didi-network:
|
||||
external: true # single shared network for all DIDI + AI platform stacks
|
||||
|
||||
volumes:
|
||||
dashboard_db_data:
|
||||
driver: local
|
||||
|
||||
services:
|
||||
# ==========================================================================
|
||||
# PostgreSQL
|
||||
# ==========================================================================
|
||||
dashboard-db:
|
||||
container_name: didiAI-dashboard-db
|
||||
image: postgres:16-alpine
|
||||
networks:
|
||||
- didi-network
|
||||
environment:
|
||||
- POSTGRES_USER=${DASHBOARD_DB_USER}
|
||||
- POSTGRES_PASSWORD=${DASHBOARD_DB_PASSWORD}
|
||||
- POSTGRES_DB=${DASHBOARD_DB_NAME}
|
||||
volumes:
|
||||
- dashboard_db_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "15432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DASHBOARD_DB_USER} -d ${DASHBOARD_DB_NAME}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- dashboard
|
||||
|
||||
# ==========================================================================
|
||||
# Dashboard API + Web UI
|
||||
# ==========================================================================
|
||||
dashboard-api:
|
||||
container_name: didiAI-dashboard
|
||||
image: didiai-dashboard
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
args:
|
||||
# SPA build-time config — Keycloak settings get baked into the JS bundle.
|
||||
# Default: local Keycloak proxied through admin nginx at /auth/.
|
||||
VITE_KEYCLOAK_URL: ${VITE_KEYCLOAK_URL:-https://10.11.10.11:3001/auth}
|
||||
VITE_KEYCLOAK_REALM: ${VITE_KEYCLOAK_REALM:-didi-admins}
|
||||
VITE_KEYCLOAK_CLIENT_ID: ${VITE_KEYCLOAK_CLIENT_ID:-ai-platform-dashboard}
|
||||
VITE_KEYCLOAK_REQUIRED_ROLE: ${VITE_KEYCLOAK_REQUIRED_ROLE:-admin}
|
||||
VITE_STAGING_MODE: ${VITE_STAGING_MODE:-false}
|
||||
ports:
|
||||
- "51300:51300"
|
||||
networks:
|
||||
- didi-network
|
||||
depends_on:
|
||||
dashboard-db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- DASHBOARD_HOST=0.0.0.0
|
||||
- DASHBOARD_PORT=51300
|
||||
- DASHBOARD_DATABASE_URL=postgresql+asyncpg://${DASHBOARD_DB_USER}:${DASHBOARD_DB_PASSWORD}@didiAI-dashboard-db:5432/${DASHBOARD_DB_NAME}
|
||||
- DASHBOARD_BRAIN_URL=${DASHBOARD_BRAIN_URL:-http://didibrain-api:8090}
|
||||
# Keycloak SSO (leave empty to disable JWT auth)
|
||||
- DASHBOARD_KEYCLOAK_URL=${DASHBOARD_KEYCLOAK_URL:-}
|
||||
- DASHBOARD_KEYCLOAK_REALM=${DASHBOARD_KEYCLOAK_REALM:-didi-clients}
|
||||
- DASHBOARD_KEYCLOAK_CLIENT_ID=${DASHBOARD_KEYCLOAK_CLIENT_ID:-ai-platform-dashboard}
|
||||
- DASHBOARD_KEYCLOAK_REQUIRED_ROLE=${DASHBOARD_KEYCLOAK_REQUIRED_ROLE:-admin}
|
||||
- DASHBOARD_STAGING_MODE=${DASHBOARD_STAGING_MODE:-false}
|
||||
# OTel — traces to Jaeger via OTel Collector
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT:-http://didi-otel-collector:4317}
|
||||
- OTEL_SERVICE_NAME=didiAI-dashboard
|
||||
# Module health endpoint overrides — point at real upstreams
|
||||
# (raw vLLM at 10.11.10.15 instead of the unbuilt FastAPI wrapper at 10.11.10.17:14100/14200)
|
||||
- DASHBOARD_EMBEDDINGS_HEALTH_URL=${DASHBOARD_EMBEDDINGS_HEALTH_URL:-http://10.11.10.15:8200/v1/models}
|
||||
- DASHBOARD_RERANK_HEALTH_URL=${DASHBOARD_RERANK_HEALTH_URL:-http://10.11.10.15:8100/v1/models}
|
||||
# Catalog runs locally on Docker DNS; /v1/status is the aggregated reachability probe
|
||||
# (catalog does not expose /v1/info — it consumes it from other modules)
|
||||
- DASHBOARD_CATALOG_HEALTH_URL=${DASHBOARD_CATALOG_HEALTH_URL:-http://didiAI-catalog-api:11000/v1/status}
|
||||
# Gateway is nginx — only /health is auth-free and inline
|
||||
- DASHBOARD_GATEWAY_HEALTH_URL=${DASHBOARD_GATEWAY_HEALTH_URL:-http://didiAI-gateway:11000/health}
|
||||
# Brain doesn't expose /v1/info — point at /health (matches catalog rationale)
|
||||
- DASHBOARD_BRAIN_HEALTH_URL=${DASHBOARD_BRAIN_HEALTH_URL:-http://didibrain-api:8090/health}
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:51300/health')"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
profiles:
|
||||
- dashboard
|
||||
209
ai_platform/modules/dashboard/deploy/setup-keycloak.sh
Normal file
209
ai_platform/modules/dashboard/deploy/setup-keycloak.sh
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
#!/usr/bin/env bash
|
||||
# Set up Keycloak client `ai-platform-dashboard` on sso.clossers.com realm `didi-clients`.
|
||||
# Idempotent — safe to re-run; existing client gets updated to match this config.
|
||||
#
|
||||
# Usage:
|
||||
# KC_ADMIN_USER=admin KC_ADMIN_PASS=admin123 ./setup-keycloak.sh
|
||||
#
|
||||
# Optional overrides:
|
||||
# KC_BASE_URL=https://sso.clossers.com
|
||||
# KC_REALM=didi-clients
|
||||
# KC_CLIENT_ID=ai-platform-dashboard
|
||||
# DASHBOARD_HOST=10.11.10.12 # used to build redirect URI
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Keycloak admin endpoints redirect public sso.clossers.com → internal
|
||||
# sso.clossers.local. Token issuer must match the host you call, so we use the
|
||||
# internal name end-to-end (this host has DNS for it).
|
||||
KC_BASE_URL="${KC_BASE_URL:-https://sso.clossers.local}"
|
||||
KC_REALM="${KC_REALM:-didi-clients}"
|
||||
KC_CLIENT_ID="${KC_CLIENT_ID:-ai-platform-dashboard}"
|
||||
KC_ADMIN_USER="${KC_ADMIN_USER:-admin}"
|
||||
KC_ADMIN_PASS="${KC_ADMIN_PASS:?Set KC_ADMIN_PASS}"
|
||||
DASHBOARD_HOST="${DASHBOARD_HOST:-10.11.10.12}"
|
||||
DASHBOARD_PORT="${DASHBOARD_PORT:-51300}"
|
||||
|
||||
echo "→ Authenticating to Keycloak master realm…"
|
||||
ADMIN_TOKEN=$(
|
||||
curl -ksS -X POST "$KC_BASE_URL/realms/master/protocol/openid-connect/token" \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "client_id=admin-cli&grant_type=password&username=$KC_ADMIN_USER&password=$KC_ADMIN_PASS" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin)['access_token'])"
|
||||
)
|
||||
[ -n "$ADMIN_TOKEN" ] || { echo "FAIL: could not get admin token"; exit 1; }
|
||||
echo " OK (token length ${#ADMIN_TOKEN})"
|
||||
|
||||
# ---------- Step 1: ensure realm role 'admin' exists ----------
|
||||
echo "→ Checking realm role 'admin' exists in realm '$KC_REALM'…"
|
||||
RAW=$(curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/roles/admin")
|
||||
if echo "$RAW" | grep -q '"name"'; then
|
||||
echo " OK — role 'admin' present"
|
||||
else
|
||||
echo " NOT found — creating…"
|
||||
curl -ksSL -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name":"admin","description":"Platform admin (DIDI + AI platform)"}' \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/roles"
|
||||
echo " CREATED"
|
||||
fi
|
||||
|
||||
# ---------- Step 2: ensure client `ai-platform-dashboard` exists ----------
|
||||
echo "→ Checking client '$KC_CLIENT_ID' exists…"
|
||||
EXISTING=$(curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$KC_CLIENT_ID")
|
||||
CLIENT_UUID=$(echo "$EXISTING" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['id'] if d else '')")
|
||||
|
||||
REDIRECTS=$(python3 -c "
|
||||
import json
|
||||
print(json.dumps([
|
||||
'http://$DASHBOARD_HOST:$DASHBOARD_PORT/admin-ai/*',
|
||||
'https://$DASHBOARD_HOST:$DASHBOARD_PORT/admin-ai/*',
|
||||
'http://localhost:$DASHBOARD_PORT/admin-ai/*',
|
||||
'http://localhost:5173/*',
|
||||
# Reverse-proxy URL (admin-dashboard nginx exposes /admin-ai/ on port 3000)
|
||||
'https://$DASHBOARD_HOST:3000/admin-ai/*',
|
||||
]))
|
||||
")
|
||||
|
||||
PAYLOAD=$(python3 -c "
|
||||
import json, os
|
||||
print(json.dumps({
|
||||
'clientId': '$KC_CLIENT_ID',
|
||||
'name': 'AI Platform Admin Dashboard',
|
||||
'description': 'Reskinned admin dashboard for the AI platform (web/llm/embeddings/rerank/audio/video/catalog/brain)',
|
||||
'rootUrl': 'http://$DASHBOARD_HOST:$DASHBOARD_PORT/admin-ai/',
|
||||
'baseUrl': '/admin-ai/',
|
||||
'enabled': True,
|
||||
'protocol': 'openid-connect',
|
||||
'publicClient': True,
|
||||
'standardFlowEnabled': True,
|
||||
'directAccessGrantsEnabled': False,
|
||||
'serviceAccountsEnabled': False,
|
||||
'frontchannelLogout': True,
|
||||
'redirectUris': $REDIRECTS,
|
||||
'webOrigins': ['+'],
|
||||
'attributes': {
|
||||
'pkce.code.challenge.method': 'S256',
|
||||
'post.logout.redirect.uris': '+',
|
||||
},
|
||||
}))
|
||||
")
|
||||
|
||||
if [ -z "$CLIENT_UUID" ]; then
|
||||
echo " NOT found — creating…"
|
||||
curl -ksSL -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients"
|
||||
CLIENT_UUID=$(
|
||||
curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$KC_CLIENT_ID" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin)[0]['id'])"
|
||||
)
|
||||
echo " CREATED uuid=$CLIENT_UUID"
|
||||
else
|
||||
echo " Found uuid=$CLIENT_UUID — updating to current spec…"
|
||||
curl -ksSL -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients/$CLIENT_UUID"
|
||||
echo " UPDATED"
|
||||
fi
|
||||
|
||||
# ---------- Step 3: smoke test JWKS reachable ----------
|
||||
echo "→ Smoke test: JWKS endpoint reachable…"
|
||||
JWKS_KEYS=$(curl -ksS \
|
||||
"$KC_BASE_URL/realms/$KC_REALM/protocol/openid-connect/certs" \
|
||||
| python3 -c "import json,sys; print(len(json.load(sys.stdin).get('keys',[])))")
|
||||
echo " OK — JWKS returns $JWKS_KEYS keys"
|
||||
|
||||
# ---------- Step 3b: ensure admin-dashboard client also exists (DIDI side) ----------
|
||||
# This is a no-op when the client already exists with correct config; included
|
||||
# so a fresh Keycloak install gets both apps wired in one go.
|
||||
ADMIN_DASH_CLIENT="admin-dashboard"
|
||||
echo "→ Checking client '$ADMIN_DASH_CLIENT' (DIDI admin) exists…"
|
||||
EXISTING_AD=$(curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$ADMIN_DASH_CLIENT")
|
||||
ADMIN_DASH_UUID=$(echo "$EXISTING_AD" | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[0]['id'] if d else '')")
|
||||
|
||||
ADMIN_DASH_REDIRECTS=$(python3 -c "
|
||||
import json
|
||||
print(json.dumps([
|
||||
'https://$DASHBOARD_HOST:3000/admin/*',
|
||||
'https://$DASHBOARD_HOST:3000/admin-backend/*',
|
||||
'http://localhost:3000/admin/*',
|
||||
]))
|
||||
")
|
||||
|
||||
ADMIN_DASH_PAYLOAD=$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({
|
||||
'clientId': '$ADMIN_DASH_CLIENT',
|
||||
'name': 'DIDI Admin Dashboard',
|
||||
'description': 'Backend admin (framework config, users, moderation queue, history)',
|
||||
'rootUrl': 'https://$DASHBOARD_HOST:3000/admin/',
|
||||
'baseUrl': '/admin/',
|
||||
'enabled': True,
|
||||
'protocol': 'openid-connect',
|
||||
'publicClient': True,
|
||||
'standardFlowEnabled': True,
|
||||
'directAccessGrantsEnabled': False,
|
||||
'redirectUris': $ADMIN_DASH_REDIRECTS,
|
||||
'webOrigins': ['+'],
|
||||
'attributes': {
|
||||
'pkce.code.challenge.method': 'S256',
|
||||
'post.logout.redirect.uris': '+',
|
||||
},
|
||||
}))
|
||||
")
|
||||
|
||||
if [ -z "$ADMIN_DASH_UUID" ]; then
|
||||
echo " NOT found — creating…"
|
||||
curl -ksSL -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$ADMIN_DASH_PAYLOAD" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients"
|
||||
ADMIN_DASH_UUID=$(
|
||||
curl -ksSL -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
"$KC_BASE_URL/admin/realms/$KC_REALM/clients?clientId=$ADMIN_DASH_CLIENT" \
|
||||
| python3 -c "import json,sys; print(json.load(sys.stdin)[0]['id'])"
|
||||
)
|
||||
echo " CREATED uuid=$ADMIN_DASH_UUID"
|
||||
else
|
||||
echo " Found uuid=$ADMIN_DASH_UUID — leaving config as-is (would overwrite custom redirect URIs)"
|
||||
fi
|
||||
|
||||
# ---------- Step 4: print summary ----------
|
||||
cat <<EOF
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Keycloak setup complete.
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
Realm: $KC_REALM
|
||||
Client: $KC_CLIENT_ID (uuid=$CLIENT_UUID)
|
||||
Role: admin (realm)
|
||||
Issuer: $KC_BASE_URL/realms/$KC_REALM
|
||||
JWKS: $KC_BASE_URL/realms/$KC_REALM/protocol/openid-connect/certs
|
||||
|
||||
REDIRECTS:
|
||||
$(python3 -c "import json; [print(f' - {u}') for u in $REDIRECTS]")
|
||||
|
||||
NEXT STEPS:
|
||||
1. Verify YOUR user has the 'admin' realm role assigned in $KC_REALM.
|
||||
(If you can already log into the DIDI admin-dashboard, you do.)
|
||||
|
||||
2. Update dashboard/.env:
|
||||
DASHBOARD_KEYCLOAK_URL=$KC_BASE_URL
|
||||
DASHBOARD_STAGING_MODE=false
|
||||
VITE_STAGING_MODE=false
|
||||
|
||||
3. Rebuild + redeploy:
|
||||
cd /home/admin365/didi_mono/ai_platform/modules/dashboard/deploy
|
||||
docker compose --profile dashboard up -d --build dashboard-api
|
||||
|
||||
4. Open http://$DASHBOARD_HOST:$DASHBOARD_PORT/v2/
|
||||
→ Keycloak login redirect → enter creds → SPA loads with your JWT.
|
||||
|
||||
EOF
|
||||
47
ai_platform/modules/dashboard/pyproject.toml
Normal file
47
ai_platform/modules/dashboard/pyproject.toml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
[project]
|
||||
name = "dashboard"
|
||||
version = "0.1.0"
|
||||
description = "Admin dashboard for web search providers, cost tracking, and config management"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"fastapi>=0.115.0",
|
||||
"uvicorn[standard]>=0.32.0",
|
||||
"pydantic>=2.9.0",
|
||||
"pydantic-settings>=2.6.0",
|
||||
"httpx>=0.27.0",
|
||||
"sqlalchemy[asyncio]>=2.0.36",
|
||||
"asyncpg>=0.30.0",
|
||||
"alembic>=1.14.0",
|
||||
"jinja2>=3.1.4",
|
||||
"python-multipart>=0.0.12",
|
||||
"python-jose[cryptography]>=3.3.0",
|
||||
"prometheus-fastapi-instrumentator>=7.0.0",
|
||||
"opentelemetry-instrumentation-fastapi>=0.50b0",
|
||||
"opentelemetry-instrumentation-httpx>=0.50b0",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.30.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-asyncio>=0.24.0",
|
||||
"pytest-cov>=4.0.0",
|
||||
"ruff>=0.8.0",
|
||||
"respx>=0.21.0",
|
||||
"mypy>=1.0.0",
|
||||
"aiosqlite>=0.20.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/dashboard"]
|
||||
|
||||
[tool.ruff]
|
||||
extend = "../../ruff.toml"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
3
ai_platform/modules/dashboard/src/dashboard/__init__.py
Normal file
3
ai_platform/modules/dashboard/src/dashboard/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"""Dashboard module — admin UI for web search providers and costs."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""FastAPI application."""
|
||||
185
ai_platform/modules/dashboard/src/dashboard/api/app.py
Normal file
185
ai_platform/modules/dashboard/src/dashboard/api/app.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""FastAPI application factory."""
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from starlette.types import Scope
|
||||
|
||||
|
||||
class SPAStaticFiles(StaticFiles):
|
||||
"""StaticFiles that falls back to index.html for client-side routes.
|
||||
|
||||
`StaticFiles(html=True)` only serves index.html for the mount root —
|
||||
/v2/brain/atoms etc. would 404 since there's no file at that path.
|
||||
React-router with BrowserRouter needs the SPA shell served for any
|
||||
unmatched URL so the JS bundle can pick up the route. We override
|
||||
`lookup_path` to return index.html as a final fallback (after the
|
||||
standard html=True attempts: path, path+".html", path+"/index.html").
|
||||
"""
|
||||
|
||||
def lookup_path(self, path: str): # type: ignore[override]
|
||||
full_path, stat_result = super().lookup_path(path)
|
||||
if stat_result is None:
|
||||
# Fall back to root index.html so React Router can handle it
|
||||
return super().lookup_path("index.html")
|
||||
return full_path, stat_result
|
||||
|
||||
from dashboard.api.routes import (
|
||||
archive,
|
||||
audit,
|
||||
brain_proxy,
|
||||
catalog,
|
||||
config,
|
||||
health,
|
||||
history,
|
||||
ingest,
|
||||
monitoring,
|
||||
pages,
|
||||
proxy,
|
||||
stats,
|
||||
)
|
||||
from dashboard.config import SettingsCache
|
||||
from dashboard.db.session import close_engine, create_all_tables, init_engine
|
||||
from dashboard.logging import configure_logging, get_logger
|
||||
from dashboard.providers.registry import ProviderRegistry
|
||||
from dashboard.retention import retention_loop
|
||||
|
||||
logger = get_logger("app")
|
||||
|
||||
STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
# React SPA built artifacts. Lives at <repo>/dashboard/web/dist; in the docker
|
||||
# image this is copied to /app/web_dist by the multi-stage Dockerfile.
|
||||
WEB_DIST_DIR = Path("/app/web_dist")
|
||||
if not WEB_DIST_DIR.exists():
|
||||
# Fall back to repo-relative path when running from a checkout (uv run uvicorn ...)
|
||||
WEB_DIST_DIR = Path(__file__).resolve().parents[3] / "web" / "dist"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
||||
settings = SettingsCache.get()
|
||||
configure_logging(settings.log_level, settings.log_json)
|
||||
logger.info("Starting Dashboard on port %d", settings.port)
|
||||
|
||||
if settings.staging_mode:
|
||||
logger.warning(
|
||||
"SECURITY: staging_mode=true — ALL authentication/RBAC is bypassed. "
|
||||
"This MUST be disabled in production (set DASHBOARD_STAGING_MODE=false)."
|
||||
)
|
||||
|
||||
init_engine(settings)
|
||||
await create_all_tables()
|
||||
|
||||
# Seed schema from hardcoded KNOWN_KEYS on first run (idempotent — skips if non-empty).
|
||||
from dashboard.api.routes.config import seed_schema_if_empty
|
||||
from dashboard.db.session import get_session
|
||||
async for seed_session in get_session():
|
||||
seeded = await seed_schema_if_empty(seed_session)
|
||||
if seeded:
|
||||
logger.info("config_schema_override seeded with %d entries", seeded)
|
||||
break
|
||||
|
||||
app.state.settings = settings
|
||||
app.state.registry = ProviderRegistry(settings)
|
||||
|
||||
# Background retention task
|
||||
retention_task = asyncio.create_task(retention_loop(settings))
|
||||
|
||||
logger.info("Dashboard started successfully")
|
||||
yield
|
||||
|
||||
retention_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await retention_task
|
||||
|
||||
await close_engine()
|
||||
logger.info("Dashboard shutdown complete")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
settings = SettingsCache.get()
|
||||
|
||||
app = FastAPI(
|
||||
title="Dashboard API",
|
||||
description="Admin dashboard for web search providers and cost tracking",
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
servers=[{"url": settings.external_url, "description": "Dashboard API"}],
|
||||
)
|
||||
|
||||
# Static files (if any) — Tailwind/HTMX come via CDN for zero-build
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
# Prometheus /metrics + OTel tracing (no-op if deps missing)
|
||||
try:
|
||||
from prometheus_fastapi_instrumentator import Instrumentator # type: ignore
|
||||
Instrumentator(should_group_status_codes=True).instrument(app).expose(
|
||||
app, endpoint="/metrics", include_in_schema=False
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
import os as _os
|
||||
_otel_ep = _os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
if _otel_ep:
|
||||
try:
|
||||
from opentelemetry import trace as _trace # type: ignore
|
||||
from opentelemetry.sdk.resources import Resource as _R # type: ignore
|
||||
from opentelemetry.sdk.trace import TracerProvider as _TP # type: ignore
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor as _BSP # type: ignore
|
||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as _Exp # type: ignore
|
||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor as _FInst # type: ignore
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor as _HXInst # type: ignore
|
||||
_provider = _TP(resource=_R.create({"service.name": _os.environ.get("OTEL_SERVICE_NAME", "didiAI-dashboard")}))
|
||||
_provider.add_span_processor(_BSP(_Exp(endpoint=_otel_ep, insecure=True)))
|
||||
_trace.set_tracer_provider(_provider)
|
||||
_FInst.instrument_app(app)
|
||||
_HXInst().instrument()
|
||||
print(f"[otel] didiAI-dashboard instrumented → {_otel_ep}")
|
||||
except ImportError as _e:
|
||||
print(f"[otel] skip: {_e}")
|
||||
|
||||
# JSON API routes — registered TWICE so they're reachable both at /api/*
|
||||
# (direct access to dashboard:51300) AND at /admin-ai/api/* (when reached
|
||||
# via the admin-dashboard nginx reverse proxy on port 3000). Same handlers,
|
||||
# both URLs Just Work.
|
||||
app.include_router(health.router, tags=["Health"])
|
||||
for prefix in ("/api", "/admin-ai/api"):
|
||||
app.include_router(stats.router, prefix=prefix, tags=["Stats"])
|
||||
app.include_router(history.router, prefix=prefix, tags=["History"])
|
||||
app.include_router(ingest.router, prefix=prefix, tags=["Ingest"])
|
||||
app.include_router(config.router, prefix=prefix, tags=["Config"])
|
||||
app.include_router(archive.router, prefix=prefix, tags=["Archive"])
|
||||
app.include_router(audit.router, prefix=prefix, tags=["Audit"])
|
||||
app.include_router(brain_proxy.router, prefix=prefix, tags=["Brain"])
|
||||
app.include_router(proxy.router, prefix=prefix, tags=["Proxy"])
|
||||
app.include_router(monitoring.router, prefix=prefix, tags=["Monitoring"])
|
||||
app.include_router(catalog.router, prefix=prefix, tags=["Catalog"])
|
||||
|
||||
# HTML pages (Jinja+HTMX legacy — kept until React reaches parity)
|
||||
app.include_router(pages.router, tags=["Pages"])
|
||||
|
||||
# React SPA mounted at /v2/ — runs side-by-side with Jinja during reskin.
|
||||
# StaticFiles(html=True) handles SPA fallback to index.html for client-side routes.
|
||||
if WEB_DIST_DIR.exists():
|
||||
app.mount(
|
||||
"/admin-ai",
|
||||
SPAStaticFiles(directory=str(WEB_DIST_DIR), html=True),
|
||||
name="web-spa",
|
||||
)
|
||||
logger.info("React SPA mounted at /admin-ai from %s", WEB_DIST_DIR)
|
||||
else:
|
||||
logger.warning(
|
||||
"React dist not found at %s — /admin-ai will return 404. Run `npm run build` in dashboard/web/.",
|
||||
WEB_DIST_DIR,
|
||||
)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
128
ai_platform/modules/dashboard/src/dashboard/api/dependencies.py
Normal file
128
ai_platform/modules/dashboard/src/dashboard/api/dependencies.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""FastAPI dependencies."""
|
||||
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.keycloak_auth import has_required_role, verify_keycloak_jwt
|
||||
from dashboard.auth import find_user_by_token, record_login
|
||||
from dashboard.config import DashboardSettings
|
||||
from dashboard.db.models import User
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.logging import get_logger
|
||||
from dashboard.providers.registry import ProviderRegistry
|
||||
|
||||
logger = get_logger("dependencies")
|
||||
|
||||
|
||||
def get_settings(request: Request) -> DashboardSettings:
|
||||
return request.app.state.settings
|
||||
|
||||
|
||||
def get_registry(request: Request) -> ProviderRegistry:
|
||||
return request.app.state.registry
|
||||
|
||||
|
||||
async def verify_bearer_token(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> User | str | dict[str, Any] | None:
|
||||
"""Resolve auth — Keycloak JWT first, legacy bearer second, anonymous last.
|
||||
|
||||
Resolution order:
|
||||
0. If `staging_mode=true`, return a synthetic "staging" principal
|
||||
1. If `keycloak_url` is set AND a Bearer token is provided, try JWT verification.
|
||||
On success, the principal is the JWT payload dict.
|
||||
2. Else if a Users row matches the token, return that User
|
||||
3. Else if the token is in settings.api_tokens (legacy), return the raw string
|
||||
4. Else (no auth configured at all): return None (endpoint open)
|
||||
5. Else raise 401
|
||||
"""
|
||||
settings: DashboardSettings = request.app.state.settings
|
||||
|
||||
if settings.staging_mode:
|
||||
return {"_staging": True, "username": "staging-mode", "_roles": []}
|
||||
|
||||
keycloak_enabled = bool(settings.keycloak_url)
|
||||
|
||||
# Snapshot if there's any local auth configured (for fallback path)
|
||||
from sqlalchemy import func, select
|
||||
|
||||
user_count = (await session.execute(select(func.count(User.id)))).scalar() or 0
|
||||
has_legacy_tokens = bool(settings.api_tokens)
|
||||
|
||||
auth_configured = keycloak_enabled or user_count > 0 or has_legacy_tokens
|
||||
if not auth_configured:
|
||||
return None # Auth disabled
|
||||
|
||||
if not authorization:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail={"error": "Authentication required"},
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
parts = authorization.split()
|
||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||
raise HTTPException(status_code=401, detail={"error": "Invalid Authorization"})
|
||||
|
||||
token = parts[1]
|
||||
|
||||
# 1) Try Keycloak JWT — JWTs are typically much longer than DB tokens
|
||||
# so we attempt this first when Keycloak is configured.
|
||||
if keycloak_enabled and token.count(".") == 2: # JWTs have 3 parts (header.payload.sig)
|
||||
try:
|
||||
payload = await verify_keycloak_jwt(token, settings)
|
||||
if not has_required_role(payload, settings.keycloak_required_role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": "Forbidden",
|
||||
"required_role": settings.keycloak_required_role,
|
||||
"user_roles": payload.get("_roles", []),
|
||||
},
|
||||
)
|
||||
return payload
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e: # noqa: BLE001
|
||||
# Not a valid JWT — fall through to local auth methods
|
||||
logger.debug("JWT path failed, falling back: %s", e)
|
||||
|
||||
# 2) DB-backed users
|
||||
if user_count > 0:
|
||||
user = await find_user_by_token(session, token)
|
||||
if user is not None:
|
||||
await record_login(session, user)
|
||||
return user
|
||||
|
||||
# 3) Legacy static tokens
|
||||
if has_legacy_tokens:
|
||||
is_valid = any(
|
||||
hmac.compare_digest(token.encode(), valid.encode())
|
||||
for valid in settings.api_tokens
|
||||
)
|
||||
if is_valid:
|
||||
return token
|
||||
|
||||
raise HTTPException(status_code=401, detail={"error": "Invalid token"})
|
||||
|
||||
|
||||
def get_username(principal: User | str | dict[str, Any] | None) -> str:
|
||||
"""Extract a display name for audit logs from the auth result."""
|
||||
if principal is None:
|
||||
return "anonymous"
|
||||
if isinstance(principal, User):
|
||||
return principal.username
|
||||
if isinstance(principal, dict):
|
||||
# Keycloak JWT payload — prefer email, then preferred_username, then sub
|
||||
return (
|
||||
principal.get("email")
|
||||
or principal.get("preferred_username")
|
||||
or principal.get("sub")
|
||||
or "keycloak-user"
|
||||
)
|
||||
return "legacy-token"
|
||||
128
ai_platform/modules/dashboard/src/dashboard/api/keycloak_auth.py
Normal file
128
ai_platform/modules/dashboard/src/dashboard/api/keycloak_auth.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Keycloak JWT verification for the AI platform dashboard.
|
||||
|
||||
We do NOT proxy /auth/ here — the React SPA hits Keycloak directly at
|
||||
`{keycloak_url}/realms/{realm}/...`. Backend's only job is to validate
|
||||
incoming Bearer JWTs against the realm's JWKS and check the required role.
|
||||
|
||||
JWKS is cached for 10 min. On signature failure (e.g., key rotation) the
|
||||
cache is invalidated and refetched once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException, status
|
||||
from jose import jwt
|
||||
from jose.exceptions import ExpiredSignatureError, JWTError
|
||||
|
||||
from dashboard.config import DashboardSettings
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("keycloak_auth")
|
||||
|
||||
_JWKS_CACHE: dict[str, Any] = {"keys": None, "fetched_at": 0.0, "url": ""}
|
||||
_JWKS_TTL_SECONDS = 600 # 10 min
|
||||
|
||||
|
||||
async def _fetch_jwks(url: str) -> dict[str, Any]:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(url)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _get_jwks(settings: DashboardSettings, force: bool = False) -> dict[str, Any]:
|
||||
"""Return JWKS for the configured realm, with TTL cache."""
|
||||
if not settings.keycloak_url:
|
||||
raise RuntimeError("Keycloak URL not configured")
|
||||
url = (
|
||||
f"{settings.keycloak_url.rstrip('/')}/realms/"
|
||||
f"{settings.keycloak_realm}/protocol/openid-connect/certs"
|
||||
)
|
||||
now = time.time()
|
||||
cached_for_url = _JWKS_CACHE["url"] == url
|
||||
fresh = cached_for_url and (now - _JWKS_CACHE["fetched_at"]) < _JWKS_TTL_SECONDS
|
||||
if not force and fresh and _JWKS_CACHE["keys"] is not None:
|
||||
return _JWKS_CACHE["keys"]
|
||||
|
||||
keys = await _fetch_jwks(url)
|
||||
_JWKS_CACHE["keys"] = keys
|
||||
_JWKS_CACHE["fetched_at"] = now
|
||||
_JWKS_CACHE["url"] = url
|
||||
logger.info(
|
||||
"JWKS refreshed (realm=%s, keys=%d)",
|
||||
settings.keycloak_realm,
|
||||
len(keys.get("keys", [])),
|
||||
)
|
||||
return keys
|
||||
|
||||
|
||||
def _expected_issuer(settings: DashboardSettings) -> str:
|
||||
return f"{settings.keycloak_url.rstrip('/')}/realms/{settings.keycloak_realm}"
|
||||
|
||||
|
||||
def _extract_roles(payload: dict[str, Any]) -> list[str]:
|
||||
"""Pick up realm roles + client roles for the configured client."""
|
||||
roles: list[str] = []
|
||||
realm_access = payload.get("realm_access") or {}
|
||||
if isinstance(realm_access, dict):
|
||||
roles.extend(realm_access.get("roles") or [])
|
||||
resource_access = payload.get("resource_access") or {}
|
||||
if isinstance(resource_access, dict):
|
||||
for client_block in resource_access.values():
|
||||
if isinstance(client_block, dict):
|
||||
roles.extend(client_block.get("roles") or [])
|
||||
return roles
|
||||
|
||||
|
||||
async def verify_keycloak_jwt(
|
||||
token: str,
|
||||
settings: DashboardSettings,
|
||||
) -> dict[str, Any]:
|
||||
"""Verify a Keycloak JWT against JWKS. Raises HTTPException on failure.
|
||||
|
||||
Returns the decoded payload + a synthetic `_roles` list flattened from
|
||||
realm_access + resource_access. Audience is NOT enforced — Keycloak
|
||||
issues tokens with audience='account' by default; instead we verify the
|
||||
issuer + signature + exp + presence of the required role.
|
||||
"""
|
||||
if not settings.keycloak_url:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Keycloak not configured",
|
||||
)
|
||||
|
||||
issuer = _expected_issuer(settings)
|
||||
|
||||
async def _decode_with_keys(force_refresh: bool) -> dict[str, Any]:
|
||||
jwks = await _get_jwks(settings, force=force_refresh)
|
||||
try:
|
||||
return jwt.decode(
|
||||
token,
|
||||
jwks,
|
||||
algorithms=["RS256"],
|
||||
issuer=issuer,
|
||||
options={"verify_aud": False},
|
||||
)
|
||||
except ExpiredSignatureError:
|
||||
raise HTTPException(status_code=401, detail="Token expired")
|
||||
|
||||
try:
|
||||
payload = await _decode_with_keys(force_refresh=False)
|
||||
except JWTError:
|
||||
# Try once with fresh JWKS in case keys rotated
|
||||
try:
|
||||
payload = await _decode_with_keys(force_refresh=True)
|
||||
except JWTError as e:
|
||||
logger.warning("JWT verify failed after JWKS refresh: %s", e)
|
||||
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")
|
||||
|
||||
payload["_roles"] = _extract_roles(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def has_required_role(payload: dict[str, Any], required_role: str) -> bool:
|
||||
return required_role in (payload.get("_roles") or [])
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""API routes."""
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
"""Claims & articles archive — permanent storage for valuable gather results.
|
||||
|
||||
Promoting a request_history row extracts its claim and evidence items and
|
||||
stores them in claims_archive + articles_archive. These tables will later
|
||||
be consumed by a dedicated claims-api that serves archived claims through
|
||||
the same /v1/gather response schema as web-api — no network search required.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import get_username, verify_bearer_token
|
||||
from dashboard.db.models import (
|
||||
ArticlesArchive,
|
||||
ClaimArticle,
|
||||
ClaimsArchive,
|
||||
RequestHistory,
|
||||
User,
|
||||
)
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("archive")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _sha256(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Read endpoints (public — will be consumed by claims-api too)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/archive/claims")
|
||||
async def list_claims(
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
q: str | None = Query(default=None, description="Case-insensitive claim search"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""List archived claims with optional search."""
|
||||
stmt = select(ClaimsArchive)
|
||||
if q:
|
||||
stmt = stmt.where(ClaimsArchive.claim.ilike(f"%{q}%"))
|
||||
stmt = stmt.order_by(desc(ClaimsArchive.created_at)).limit(limit).offset(offset)
|
||||
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
|
||||
count_stmt = select(func.count(ClaimsArchive.id))
|
||||
if q:
|
||||
count_stmt = count_stmt.where(ClaimsArchive.claim.ilike(f"%{q}%"))
|
||||
total = (await session.execute(count_stmt)).scalar() or 0
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": r.id,
|
||||
"claim": r.claim,
|
||||
"verdict": r.verdict,
|
||||
"confidence": float(r.confidence) if r.confidence is not None else None,
|
||||
"summary": r.summary,
|
||||
"primary_country": r.primary_country,
|
||||
"detected_language": r.detected_language,
|
||||
"promoted_by": r.promoted_by,
|
||||
"tags": r.tags,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
return {"items": items, "total": total, "offset": offset, "limit": limit}
|
||||
|
||||
|
||||
@router.get("/archive/claims/{claim_id}")
|
||||
async def get_claim(
|
||||
claim_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Get a single claim with its linked articles."""
|
||||
claim = (
|
||||
await session.execute(select(ClaimsArchive).where(ClaimsArchive.id == claim_id))
|
||||
).scalar_one_or_none()
|
||||
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=404, detail="Claim not found")
|
||||
|
||||
# Load linked articles
|
||||
links_stmt = (
|
||||
select(ClaimArticle, ArticlesArchive)
|
||||
.join(ArticlesArchive, ClaimArticle.article_id == ArticlesArchive.id)
|
||||
.where(ClaimArticle.claim_id == claim_id)
|
||||
)
|
||||
links = (await session.execute(links_stmt)).all()
|
||||
|
||||
articles = [
|
||||
{
|
||||
"id": article.id,
|
||||
"url": article.url,
|
||||
"title": article.title,
|
||||
"publisher": article.publisher,
|
||||
"published_at": article.published_at.isoformat()
|
||||
if article.published_at
|
||||
else None,
|
||||
"retrieved_at": article.retrieved_at.isoformat()
|
||||
if article.retrieved_at
|
||||
else None,
|
||||
"credibility_score": float(article.credibility_score)
|
||||
if article.credibility_score is not None
|
||||
else None,
|
||||
"relevance_score": float(link.relevance_score)
|
||||
if link.relevance_score is not None
|
||||
else None,
|
||||
"snippet": link.snippet,
|
||||
"full_text": article.full_text,
|
||||
}
|
||||
for link, article in links
|
||||
]
|
||||
|
||||
return {
|
||||
"id": claim.id,
|
||||
"claim": claim.claim,
|
||||
"verdict": claim.verdict,
|
||||
"confidence": float(claim.confidence) if claim.confidence is not None else None,
|
||||
"summary": claim.summary,
|
||||
"primary_country": claim.primary_country,
|
||||
"detected_language": claim.detected_language,
|
||||
"entities": claim.entities,
|
||||
"promoted_by": claim.promoted_by,
|
||||
"tags": claim.tags,
|
||||
"created_at": claim.created_at.isoformat() if claim.created_at else None,
|
||||
"source_request_id": claim.source_request_id,
|
||||
"articles": articles,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Write endpoints (auth required)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.post("/archive/promote/{request_id}")
|
||||
async def promote_request(
|
||||
request_id: str,
|
||||
principal: User | str | None = Depends(verify_bearer_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Promote a request_history row into the permanent archive.
|
||||
|
||||
Extracts the claim, search_context and evidence items from the
|
||||
stored raw_response and creates/updates the corresponding rows.
|
||||
"""
|
||||
row = (
|
||||
await session.execute(
|
||||
select(RequestHistory).where(RequestHistory.request_id == request_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Request not found")
|
||||
|
||||
if row.endpoint != "/v1/gather":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Only /v1/gather requests can be promoted",
|
||||
)
|
||||
|
||||
if not row.raw_response:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Request has no stored response (too large or not a success)",
|
||||
)
|
||||
|
||||
raw = row.raw_response
|
||||
claim_text = raw.get("claim") or row.claim
|
||||
if not claim_text:
|
||||
raise HTTPException(status_code=400, detail="Missing claim text")
|
||||
|
||||
claim_hash = _sha256(claim_text.strip().lower())
|
||||
context = raw.get("search_context") or {}
|
||||
evidence_items = raw.get("evidence") or []
|
||||
|
||||
# Upsert claim
|
||||
existing_claim = (
|
||||
await session.execute(
|
||||
select(ClaimsArchive).where(ClaimsArchive.claim_hash == claim_hash)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
username = get_username(principal)
|
||||
|
||||
if existing_claim is None:
|
||||
claim_row = ClaimsArchive(
|
||||
claim=claim_text,
|
||||
claim_hash=claim_hash,
|
||||
source_request_id=request_id,
|
||||
primary_country=context.get("primary_country"),
|
||||
detected_language=context.get("detected_language"),
|
||||
entities=context.get("entities"),
|
||||
promoted_by=username,
|
||||
tags=[],
|
||||
summary=_build_auto_summary(evidence_items),
|
||||
)
|
||||
session.add(claim_row)
|
||||
await session.flush() # need the id for the link rows
|
||||
else:
|
||||
claim_row = existing_claim
|
||||
# Refresh metadata on re-promotion
|
||||
claim_row.source_request_id = request_id
|
||||
claim_row.promoted_by = username
|
||||
claim_row.primary_country = context.get("primary_country") or claim_row.primary_country
|
||||
claim_row.detected_language = (
|
||||
context.get("detected_language") or claim_row.detected_language
|
||||
)
|
||||
claim_row.entities = context.get("entities") or claim_row.entities
|
||||
|
||||
# Upsert articles + links
|
||||
created_articles = 0
|
||||
linked_articles = 0
|
||||
for item in evidence_items:
|
||||
url = item.get("url")
|
||||
if not url:
|
||||
continue
|
||||
url_hash = _sha256(url)
|
||||
|
||||
existing_article = (
|
||||
await session.execute(
|
||||
select(ArticlesArchive).where(ArticlesArchive.url_hash == url_hash)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
published_at = _parse_datetime(item.get("published_at"))
|
||||
retrieved_at = _parse_datetime(item.get("retrieved_at")) or datetime.now(
|
||||
timezone.utc
|
||||
)
|
||||
|
||||
if existing_article is None:
|
||||
article_row = ArticlesArchive(
|
||||
url=url,
|
||||
url_hash=url_hash,
|
||||
canonical_url=item.get("canonical_url"),
|
||||
title=item.get("title"),
|
||||
full_text=item.get("full_text"),
|
||||
publisher=item.get("publisher"),
|
||||
published_at=published_at,
|
||||
retrieved_at=retrieved_at,
|
||||
extraction_method=(item.get("provenance") or {}).get(
|
||||
"extraction_method"
|
||||
),
|
||||
credibility_score=item.get("credibility_score"),
|
||||
)
|
||||
session.add(article_row)
|
||||
await session.flush()
|
||||
created_articles += 1
|
||||
else:
|
||||
article_row = existing_article
|
||||
if item.get("full_text") and not article_row.full_text:
|
||||
article_row.full_text = item["full_text"]
|
||||
|
||||
# Link claim ↔ article
|
||||
existing_link = (
|
||||
await session.execute(
|
||||
select(ClaimArticle).where(
|
||||
ClaimArticle.claim_id == claim_row.id,
|
||||
ClaimArticle.article_id == article_row.id,
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if existing_link is None:
|
||||
session.add(
|
||||
ClaimArticle(
|
||||
claim_id=claim_row.id,
|
||||
article_id=article_row.id,
|
||||
relevance_score=item.get("relevance_score"),
|
||||
snippet=item.get("snippet"),
|
||||
)
|
||||
)
|
||||
linked_articles += 1
|
||||
|
||||
await session.commit()
|
||||
|
||||
logger.info(
|
||||
"Promoted %s: claim_id=%d articles+%d links+%d by=%s",
|
||||
request_id,
|
||||
claim_row.id,
|
||||
created_articles,
|
||||
linked_articles,
|
||||
username,
|
||||
)
|
||||
|
||||
return {
|
||||
"claim_id": claim_row.id,
|
||||
"claim_hash": claim_hash,
|
||||
"articles_created": created_articles,
|
||||
"articles_linked": linked_articles,
|
||||
"promoted_by": username,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helpers
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def _build_auto_summary(evidence_items: list[dict]) -> str | None:
|
||||
"""Build a basic auto-summary from evidence titles when no LLM summary exists."""
|
||||
if not evidence_items:
|
||||
return None
|
||||
titles = [e.get("title") for e in evidence_items if e.get("title")]
|
||||
if not titles:
|
||||
return None
|
||||
return " · ".join(titles[:3])[:800]
|
||||
|
||||
|
||||
def _parse_datetime(value: Any) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
"""Audit log endpoint — chronological mutation history."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import verify_bearer_token
|
||||
from dashboard.db.models import AuditLog
|
||||
from dashboard.db.session import get_session
|
||||
|
||||
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
|
||||
@router.get("/audit")
|
||||
async def list_audit(
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
username: str | None = Query(default=None),
|
||||
action: str | None = Query(default=None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Paginated audit log entries, newest first."""
|
||||
where_clauses = []
|
||||
if username:
|
||||
where_clauses.append(AuditLog.username == username)
|
||||
if action:
|
||||
where_clauses.append(AuditLog.action == action)
|
||||
|
||||
base = select(AuditLog)
|
||||
if where_clauses:
|
||||
for c in where_clauses:
|
||||
base = base.where(c)
|
||||
|
||||
# Total
|
||||
from sqlalchemy import func
|
||||
|
||||
count_q = select(func.count(AuditLog.id))
|
||||
if where_clauses:
|
||||
for c in where_clauses:
|
||||
count_q = count_q.where(c)
|
||||
total = (await session.execute(count_q)).scalar() or 0
|
||||
|
||||
rows = (
|
||||
await session.execute(
|
||||
base.order_by(desc(AuditLog.timestamp)).limit(limit).offset(offset)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": r.id,
|
||||
"timestamp": r.timestamp.isoformat() if r.timestamp else None,
|
||||
"username": r.username,
|
||||
"action": r.action,
|
||||
"target": r.target,
|
||||
"old_value": r.old_value,
|
||||
"new_value": r.new_value,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
"""Thin proxy from /api/brain/* → didibrain-api.
|
||||
|
||||
Why a proxy: the React SPA is served from dashboard:51300 and the brain admin
|
||||
endpoints live at brain:8090. Calling brain directly from the browser would
|
||||
require CORS + separate base URL. Easier to forward through dashboard FastAPI
|
||||
— same origin, same auth surface.
|
||||
|
||||
Auth model: GET is read-only admin behind the VPN (no token). Mutations
|
||||
(POST/PATCH/DELETE) require dashboard bearer token via verify_bearer_token,
|
||||
matching /api/config.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
|
||||
from dashboard.api.dependencies import verify_bearer_token
|
||||
from dashboard.config import DashboardSettings, SettingsCache
|
||||
from dashboard.db.models import User
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("brain_proxy")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Whitelist of admin endpoints exposed by brain through this proxy.
|
||||
# We do NOT expose /v1/analysis_atom/lookup, POST /v1/analysis_atom (the write
|
||||
# endpoint), or PATCH for force-gold via the proxy — those are agent-v3 only,
|
||||
# direct path. The proxy is only for dashboard-driven admin browsing.
|
||||
_ALLOWED_PREFIXES = (
|
||||
"/v1/analysis_atom/list",
|
||||
"/v1/analysis_atom/stats",
|
||||
"/v1/analysis_atom/", # GET / DELETE individual atom
|
||||
"/v1/verification_cache/list",
|
||||
"/v1/verification_cache/", # GET / DELETE individual cache row
|
||||
# Phase D2 — fact status admin browser, audit log, cache ops
|
||||
"/v1/fact_status/list",
|
||||
"/v1/fact_status/", # GET / PATCH individual fact + /:id/versions
|
||||
"/v1/cache/audit_log",
|
||||
"/v1/cache/invalidate",
|
||||
# Phase D2 — temporal canonicalize (admin can preview rewrite)
|
||||
"/v1/canonicalize",
|
||||
"/v1/taxonomy",
|
||||
"/v1/taxonomy/reload",
|
||||
"/health",
|
||||
)
|
||||
|
||||
|
||||
def _is_allowed(path: str) -> bool:
|
||||
return any(path == p or path.startswith(p) for p in _ALLOWED_PREFIXES)
|
||||
|
||||
|
||||
def _settings() -> DashboardSettings:
|
||||
return SettingsCache.get()
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/brain/{full_path:path}",
|
||||
methods=["GET"],
|
||||
tags=["Brain"],
|
||||
)
|
||||
async def brain_proxy_read(full_path: str, request: Request) -> Response:
|
||||
"""Read-only proxy /api/brain/<path> → {brain_url}/<path>."""
|
||||
return await _do_proxy(full_path, request)
|
||||
|
||||
|
||||
@router.api_route(
|
||||
"/brain/{full_path:path}",
|
||||
methods=["POST", "PATCH", "DELETE"],
|
||||
tags=["Brain"],
|
||||
)
|
||||
async def brain_proxy_mutate(
|
||||
full_path: str,
|
||||
request: Request,
|
||||
_principal: User | str | None = Depends(verify_bearer_token),
|
||||
) -> Response:
|
||||
"""Mutating proxy — requires bearer token (same as /api/config mutations)."""
|
||||
return await _do_proxy(full_path, request)
|
||||
|
||||
|
||||
async def _do_proxy(full_path: str, request: Request) -> Response:
|
||||
settings = _settings()
|
||||
target_path = "/" + full_path.lstrip("/")
|
||||
|
||||
if not _is_allowed(target_path):
|
||||
raise HTTPException(status_code=404, detail=f"Not a proxied path: {target_path}")
|
||||
|
||||
method = request.method.upper()
|
||||
|
||||
upstream = settings.brain_url.rstrip("/") + target_path
|
||||
body = await request.body()
|
||||
fwd_headers = {
|
||||
k: v
|
||||
for k, v in request.headers.items()
|
||||
if k.lower() in ("content-type", "accept", "x-request-id")
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
try:
|
||||
r = await client.request(
|
||||
method=method,
|
||||
url=upstream,
|
||||
params=dict(request.query_params),
|
||||
content=body if body else None,
|
||||
headers=fwd_headers,
|
||||
)
|
||||
except httpx.TimeoutException as e:
|
||||
logger.warning("brain_proxy_timeout path=%s err=%s", target_path, e)
|
||||
raise HTTPException(status_code=504, detail="brain upstream timeout")
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("brain_proxy_error path=%s err=%s", target_path, e)
|
||||
raise HTTPException(status_code=502, detail=f"brain upstream error: {e}")
|
||||
|
||||
pass_headers = {
|
||||
k: v
|
||||
for k, v in r.headers.items()
|
||||
if k.lower() not in ("transfer-encoding", "content-encoding", "connection")
|
||||
}
|
||||
return Response(
|
||||
content=r.content,
|
||||
status_code=r.status_code,
|
||||
headers=pass_headers,
|
||||
media_type=r.headers.get("content-type"),
|
||||
)
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
"""DB-backed catalog of AI models / extractors with CRUD (Val 2).
|
||||
|
||||
Satisfies the caiet requirement to administer models/extractors "conform datelor
|
||||
din baza de date". List is read-only; create/update/delete require auth and are
|
||||
audit-logged (same pattern as config mutations).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import get_username, verify_bearer_token
|
||||
from dashboard.db.models import AuditLog, CatalogEntry, User
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("catalog")
|
||||
# RBAC: protected when auth is configured; mutations additionally resolve the
|
||||
# principal for audit. Open in dev when no auth is configured.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
_KINDS = {"model", "extractor"}
|
||||
|
||||
|
||||
class CatalogIn(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
kind: str = Field(default="model", description="model | extractor")
|
||||
name: str = Field(..., min_length=1, max_length=128)
|
||||
display_name: str | None = None
|
||||
service: str = Field(..., min_length=1, max_length=32)
|
||||
capabilities: Any | None = None
|
||||
context_length: int | None = Field(default=None, ge=0)
|
||||
supports_cpu: bool = False
|
||||
supports_gpu: bool = False
|
||||
quantization: str | None = None
|
||||
endpoint: str | None = None
|
||||
limits: Any | None = None
|
||||
cost_per_token: float | None = Field(default=None, ge=0)
|
||||
enabled: bool = True
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class CatalogPatch(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
display_name: str | None = None
|
||||
capabilities: Any | None = None
|
||||
context_length: int | None = Field(default=None, ge=0)
|
||||
supports_cpu: bool | None = None
|
||||
supports_gpu: bool | None = None
|
||||
quantization: str | None = None
|
||||
endpoint: str | None = None
|
||||
limits: Any | None = None
|
||||
cost_per_token: float | None = Field(default=None, ge=0)
|
||||
enabled: bool | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
def _serialize(e: CatalogEntry) -> dict[str, Any]:
|
||||
return {
|
||||
"id": e.id,
|
||||
"kind": e.kind,
|
||||
"name": e.name,
|
||||
"display_name": e.display_name,
|
||||
"service": e.service,
|
||||
"capabilities": e.capabilities,
|
||||
"context_length": e.context_length,
|
||||
"supports_cpu": e.supports_cpu,
|
||||
"supports_gpu": e.supports_gpu,
|
||||
"quantization": e.quantization,
|
||||
"endpoint": e.endpoint,
|
||||
"limits": e.limits,
|
||||
"cost_per_token": float(e.cost_per_token) if e.cost_per_token is not None else None,
|
||||
"enabled": e.enabled,
|
||||
"notes": e.notes,
|
||||
"updated_at": e.updated_at.isoformat() if e.updated_at else None,
|
||||
"updated_by": e.updated_by,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
async def list_catalog(
|
||||
kind: str | None = None,
|
||||
service: str | None = None,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""List catalog entries, optionally filtered by kind/service."""
|
||||
|
||||
stmt = select(CatalogEntry)
|
||||
if kind:
|
||||
stmt = stmt.where(CatalogEntry.kind == kind)
|
||||
if service:
|
||||
stmt = stmt.where(CatalogEntry.service == service)
|
||||
stmt = stmt.order_by(CatalogEntry.service, CatalogEntry.name)
|
||||
rows = (await session.execute(stmt)).scalars().all()
|
||||
return {"items": [_serialize(r) for r in rows], "total": len(rows)}
|
||||
|
||||
|
||||
@router.post("/catalog")
|
||||
async def create_catalog(
|
||||
body: CatalogIn,
|
||||
principal: User | str | None = Depends(verify_bearer_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a catalog entry (auth + audit)."""
|
||||
|
||||
if body.kind not in _KINDS:
|
||||
raise HTTPException(status_code=422, detail=f"kind must be one of {_KINDS}")
|
||||
dup = (
|
||||
await session.execute(
|
||||
select(CatalogEntry).where(
|
||||
CatalogEntry.service == body.service, CatalogEntry.name == body.name
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if dup is not None:
|
||||
raise HTTPException(
|
||||
status_code=409, detail=f"{body.service}/{body.name} already exists"
|
||||
)
|
||||
|
||||
username = get_username(principal)
|
||||
entry = CatalogEntry(**body.model_dump(), updated_by=username)
|
||||
session.add(entry)
|
||||
session.add(
|
||||
AuditLog(username=username, action="catalog.create",
|
||||
target=f"{body.service}/{body.name}",
|
||||
old_value={}, new_value=body.model_dump())
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
logger.info("Catalog entry %s/%s created by %s", body.service, body.name, username)
|
||||
return _serialize(entry)
|
||||
|
||||
|
||||
@router.put("/catalog/{entry_id}")
|
||||
async def update_catalog(
|
||||
entry_id: int,
|
||||
body: CatalogPatch,
|
||||
principal: User | str | None = Depends(verify_bearer_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Update mutable fields of a catalog entry (auth + audit)."""
|
||||
|
||||
entry = await session.get(CatalogEntry, entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="catalog entry not found")
|
||||
|
||||
changes = body.model_dump(exclude_unset=True)
|
||||
if not changes:
|
||||
raise HTTPException(status_code=422, detail="no fields to update")
|
||||
old = _serialize(entry)
|
||||
for field, value in changes.items():
|
||||
setattr(entry, field, value)
|
||||
entry.updated_at = datetime.now(timezone.utc)
|
||||
username = get_username(principal)
|
||||
entry.updated_by = username
|
||||
|
||||
session.add(
|
||||
AuditLog(username=username, action="catalog.update",
|
||||
target=f"{entry.service}/{entry.name}", old_value=old, new_value=changes)
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(entry)
|
||||
return _serialize(entry)
|
||||
|
||||
|
||||
@router.delete("/catalog/{entry_id}")
|
||||
async def delete_catalog(
|
||||
entry_id: int,
|
||||
principal: User | str | None = Depends(verify_bearer_token),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict[str, Any]:
|
||||
"""Delete a catalog entry (auth + audit)."""
|
||||
|
||||
entry = await session.get(CatalogEntry, entry_id)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="catalog entry not found")
|
||||
target = f"{entry.service}/{entry.name}"
|
||||
old = _serialize(entry)
|
||||
username = get_username(principal)
|
||||
await session.delete(entry)
|
||||
session.add(
|
||||
AuditLog(username=username, action="catalog.delete", target=target,
|
||||
old_value=old, new_value={})
|
||||
)
|
||||
await session.commit()
|
||||
return {"deleted": entry_id, "target": target}
|
||||
1636
ai_platform/modules/dashboard/src/dashboard/api/routes/config.py
Normal file
1636
ai_platform/modules/dashboard/src/dashboard/api/routes/config.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,28 @@
|
|||
"""Health endpoints."""
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from sqlalchemy import text
|
||||
|
||||
from dashboard.db.session import get_session_factory
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health() -> dict:
|
||||
try:
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
await session.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception as e:
|
||||
return {"status": "degraded", "db": False, "error": str(e)}
|
||||
return {"status": "healthy", "db": db_ok}
|
||||
|
||||
|
||||
@router.get("/ready")
|
||||
async def ready(request: Request) -> dict:
|
||||
return {
|
||||
"ready": hasattr(request.app.state, "settings")
|
||||
and hasattr(request.app.state, "registry")
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"""Request history endpoints."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import desc, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import verify_bearer_token
|
||||
from dashboard.db.models import RequestHistory
|
||||
from dashboard.db.session import get_session
|
||||
|
||||
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def list_history(
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
module: str | None = Query(default=None),
|
||||
tier: str | None = Query(default=None),
|
||||
provider: str | None = Query(default=None),
|
||||
endpoint: str | None = Query(default=None),
|
||||
hours: int = Query(default=168, ge=1, le=720), # default 7 days
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""List recent requests with filters."""
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
q = select(RequestHistory).where(RequestHistory.created_at >= since)
|
||||
if module:
|
||||
q = q.where(RequestHistory.module == module)
|
||||
if tier:
|
||||
q = q.where(RequestHistory.tier == tier)
|
||||
if provider:
|
||||
q = q.where(RequestHistory.provider == provider)
|
||||
if endpoint:
|
||||
q = q.where(RequestHistory.endpoint == endpoint)
|
||||
q = q.order_by(desc(RequestHistory.created_at)).limit(limit).offset(offset)
|
||||
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
items = [_row_to_dict(r) for r in rows]
|
||||
return {"items": items, "count": len(items), "offset": offset}
|
||||
|
||||
|
||||
@router.get("/history/{key}")
|
||||
async def get_history(
|
||||
key: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Get one request by either numeric id (UI uses this) or request_id string."""
|
||||
# Numeric path → row primary key. String path → request_id (for service-to-service
|
||||
# tooling that already knows the UUID it sent on POST /api/ingest/event).
|
||||
try:
|
||||
numeric_id = int(key)
|
||||
q = select(RequestHistory).where(RequestHistory.id == numeric_id)
|
||||
except ValueError:
|
||||
q = select(RequestHistory).where(RequestHistory.request_id == key)
|
||||
row = (await session.execute(q)).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
return _row_to_dict(row, full=True)
|
||||
|
||||
|
||||
def _row_to_dict(row: RequestHistory, full: bool = False) -> dict:
|
||||
out = {
|
||||
"id": row.id,
|
||||
"request_id": row.request_id,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
"module": row.module,
|
||||
"tier": row.tier,
|
||||
"endpoint": row.endpoint,
|
||||
"provider": row.provider,
|
||||
"claim": row.claim,
|
||||
"query": row.query,
|
||||
"duration_ms": row.duration_ms,
|
||||
"status_code": row.status_code,
|
||||
"results_count": row.results_count,
|
||||
"evidence_count": row.evidence_count,
|
||||
"error": row.error,
|
||||
"cost_usd": float(row.cost_usd) if row.cost_usd is not None else None,
|
||||
"user_id": row.user_id,
|
||||
}
|
||||
if full:
|
||||
out["stages"] = row.stages
|
||||
out["raw_request"] = row.raw_request
|
||||
out["raw_response"] = row.raw_response
|
||||
return out
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
"""Event ingest endpoint — receives request events from web-api."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.db.models import RequestHistory
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.logging import get_logger
|
||||
from dashboard.pricing import estimate_cost
|
||||
|
||||
logger = get_logger("ingest")
|
||||
|
||||
# Service-to-service ingest — no auth (VPN-internal, web-api → dashboard)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class IngestEvent(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
request_id: str = Field(max_length=64)
|
||||
# Source service. Senders that don't pass it default to 'web' for back-compat
|
||||
# with the original DashboardEventSink in the web-api module.
|
||||
module: str = Field(default="web", max_length=16)
|
||||
tier: str = Field(default="free", max_length=16)
|
||||
endpoint: str = Field(max_length=64)
|
||||
provider: str | None = Field(default=None, max_length=32)
|
||||
claim: str | None = None
|
||||
query: str | None = None
|
||||
duration_ms: int | None = None
|
||||
status_code: int | None = None
|
||||
results_count: int | None = None
|
||||
evidence_count: int | None = None
|
||||
error: str | None = None
|
||||
cost_usd: float | None = None
|
||||
user_id: str | None = Field(default=None, max_length=128)
|
||||
stages: list[dict[str, Any]] | None = None
|
||||
raw_request: dict[str, Any] | None = None
|
||||
raw_response: dict[str, Any] | None = None
|
||||
|
||||
|
||||
@router.post("/ingest/event")
|
||||
async def ingest_event(
|
||||
event: IngestEvent,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Receive a single request event from web-api middleware."""
|
||||
# Trim bulky fields
|
||||
claim = event.claim[:2000] if event.claim else None
|
||||
query = event.query[:2000] if event.query else None
|
||||
error = event.error[:2000] if event.error else None
|
||||
|
||||
# Compute cost if the sender didn't provide it
|
||||
cost = event.cost_usd
|
||||
if cost is None:
|
||||
cost = estimate_cost(event.endpoint, event.tier, event.provider)
|
||||
|
||||
row = RequestHistory(
|
||||
request_id=event.request_id,
|
||||
module=event.module,
|
||||
tier=event.tier,
|
||||
endpoint=event.endpoint,
|
||||
provider=event.provider,
|
||||
claim=claim,
|
||||
query=query,
|
||||
duration_ms=event.duration_ms,
|
||||
status_code=event.status_code,
|
||||
results_count=event.results_count,
|
||||
evidence_count=event.evidence_count,
|
||||
error=error,
|
||||
cost_usd=cost,
|
||||
user_id=event.user_id,
|
||||
stages=event.stages,
|
||||
raw_request=event.raw_request,
|
||||
raw_response=event.raw_response,
|
||||
)
|
||||
session.add(row)
|
||||
try:
|
||||
await session.commit()
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
logger.warning("Ingest failed for %s: %s", event.request_id, e)
|
||||
return {"stored": False, "error": str(e)}
|
||||
return {"stored": True, "id": row.id}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
"""Central AI monitoring panel (Val 1).
|
||||
|
||||
Aggregates, in one place, data that already exists across the platform but was
|
||||
not surfaced in the UI:
|
||||
- health of all AI services (concurrent probes)
|
||||
- RabbitMQ queue depths (management API)
|
||||
- latency percentiles p50/p90/p99 (Prometheus)
|
||||
|
||||
Every source is fail-open: a missing/unreachable source yields a disabled or
|
||||
empty section, never a 500 — the panel must never take the dashboard down.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from dashboard.api.dependencies import verify_bearer_token
|
||||
from dashboard.config import SettingsCache
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("monitoring")
|
||||
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
|
||||
def _service_endpoints() -> dict[str, str]:
|
||||
"""All AI services to monitor → health/info URL (env-overridable).
|
||||
|
||||
Prefers ``/v1/info`` where the service exposes it (richer payload), else
|
||||
``/health``. Override any entry with ``DASHBOARD_<ID>_HEALTH_URL``.
|
||||
"""
|
||||
|
||||
s = SettingsCache.get()
|
||||
|
||||
def envurl(key: str, default: str) -> str:
|
||||
return os.environ.get(f"DASHBOARD_{key.upper()}_HEALTH_URL", default)
|
||||
|
||||
return {
|
||||
"web": envurl("web", f"{s.web_api_url}/v1/info"),
|
||||
"llm": envurl("llm", f"{s.llm_api_url}/v1/info"),
|
||||
"embeddings": envurl("embeddings", "http://10.11.10.17:14100/health"),
|
||||
"rerank": envurl("rerank", "http://10.11.10.17:14200/health"),
|
||||
"audio": envurl("audio", "http://10.11.10.17:54300/v1/info"),
|
||||
"video": envurl("video", "http://10.11.10.17:54600/v1/info"),
|
||||
"extractors": envurl("extractors", "http://10.11.10.17:54400/health"),
|
||||
"forensic": envurl("forensic", "http://10.11.10.17:54700/health"),
|
||||
"catalog": envurl("catalog", "http://10.11.10.17:11000/v1/info"),
|
||||
"gateway": envurl("gateway", "http://10.11.10.17:11000/v1/info"),
|
||||
"brain": envurl("brain", f"{s.brain_url}/v1/info"),
|
||||
}
|
||||
|
||||
|
||||
async def _probe(client: httpx.AsyncClient, module_id: str, url: str) -> dict[str, Any]:
|
||||
"""Probe one service health endpoint; normalized, never raises."""
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
r = await client.get(url)
|
||||
latency_ms = round((time.monotonic() - started) * 1000, 1)
|
||||
if r.status_code >= 500:
|
||||
status = "down"
|
||||
elif r.status_code >= 400:
|
||||
status = "degraded"
|
||||
else:
|
||||
status = "healthy"
|
||||
info: Any = None
|
||||
if status == "healthy":
|
||||
try:
|
||||
info = r.json()
|
||||
except Exception: # noqa: BLE001 - body is best-effort
|
||||
info = None
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": status,
|
||||
"http_status": r.status_code,
|
||||
"latency_ms": latency_ms,
|
||||
"info": info,
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "down",
|
||||
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
"error": "timeout (>3s)",
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "down",
|
||||
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
"error": str(e)[:200],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/monitoring/services")
|
||||
async def monitoring_services() -> dict[str, Any]:
|
||||
"""Concurrent health of every AI service, plus a roll-up summary."""
|
||||
|
||||
endpoints = _service_endpoints()
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
results = await asyncio.gather(
|
||||
*(_probe(client, mid, url) for mid, url in endpoints.items())
|
||||
)
|
||||
summary = {"healthy": 0, "degraded": 0, "down": 0, "total": len(results)}
|
||||
for r in results:
|
||||
summary[r["status"]] = summary.get(r["status"], 0) + 1
|
||||
return {"services": list(results), "summary": summary}
|
||||
|
||||
|
||||
@router.get("/monitoring/queues")
|
||||
async def monitoring_queues() -> dict[str, Any]:
|
||||
"""RabbitMQ queue depths via the management API (fail-open if unset)."""
|
||||
|
||||
s = SettingsCache.get()
|
||||
if not s.rabbitmq_mgmt_url:
|
||||
return {"enabled": False, "queues": [], "reason": "rabbitmq_mgmt_url not set"}
|
||||
|
||||
url = f"{s.rabbitmq_mgmt_url.rstrip('/')}/api/queues"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=4.0) as client:
|
||||
r = await client.get(
|
||||
url, auth=(s.rabbitmq_mgmt_user, s.rabbitmq_mgmt_password)
|
||||
)
|
||||
if r.status_code != 200:
|
||||
return {"enabled": True, "queues": [], "error": f"HTTP {r.status_code}"}
|
||||
queues = [
|
||||
{
|
||||
"name": q.get("name"),
|
||||
"vhost": q.get("vhost"),
|
||||
"messages": q.get("messages", 0),
|
||||
"ready": q.get("messages_ready", 0),
|
||||
"unacked": q.get("messages_unacknowledged", 0),
|
||||
"consumers": q.get("consumers", 0),
|
||||
"state": q.get("state"),
|
||||
}
|
||||
for q in (r.json() if isinstance(r.json(), list) else [])
|
||||
]
|
||||
queues.sort(key=lambda q: q["messages"], reverse=True)
|
||||
return {"enabled": True, "queues": queues}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("rabbitmq_probe_failed: %s", e)
|
||||
return {"enabled": True, "queues": [], "error": str(e)[:200]}
|
||||
|
||||
|
||||
async def _prom_quantile(
|
||||
client: httpx.AsyncClient, base: str, q: float
|
||||
) -> dict[str, float]:
|
||||
"""Query one latency quantile per Prometheus job."""
|
||||
|
||||
expr = (
|
||||
f"histogram_quantile({q}, sum(rate("
|
||||
f"http_request_duration_seconds_bucket[5m])) by (le, job))"
|
||||
)
|
||||
r = await client.get(f"{base}/api/v1/query", params={"query": expr})
|
||||
out: dict[str, float] = {}
|
||||
if r.status_code != 200:
|
||||
return out
|
||||
for series in r.json().get("data", {}).get("result", []):
|
||||
job = series.get("metric", {}).get("job")
|
||||
val = series.get("value", [None, None])[1]
|
||||
if job and val is not None:
|
||||
try:
|
||||
out[job] = round(float(val) * 1000, 1) # seconds → ms
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/monitoring/latency")
|
||||
async def monitoring_latency() -> dict[str, Any]:
|
||||
"""p50/p90/p99 request latency per service from Prometheus (fail-open)."""
|
||||
|
||||
s = SettingsCache.get()
|
||||
if not s.prometheus_url:
|
||||
return {"enabled": False, "services": [], "reason": "prometheus_url not set"}
|
||||
|
||||
base = s.prometheus_url.rstrip("/")
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
p50, p90, p99 = await asyncio.gather(
|
||||
_prom_quantile(client, base, 0.5),
|
||||
_prom_quantile(client, base, 0.9),
|
||||
_prom_quantile(client, base, 0.99),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("prometheus_query_failed: %s", e)
|
||||
return {"enabled": True, "services": [], "error": str(e)[:200]}
|
||||
|
||||
jobs = sorted(set(p50) | set(p90) | set(p99))
|
||||
services = [
|
||||
{
|
||||
"job": job,
|
||||
"p50_ms": p50.get(job),
|
||||
"p90_ms": p90.get(job),
|
||||
"p99_ms": p99.get(job),
|
||||
}
|
||||
for job in jobs
|
||||
]
|
||||
return {"enabled": True, "services": services}
|
||||
498
ai_platform/modules/dashboard/src/dashboard/api/routes/pages.py
Normal file
498
ai_platform/modules/dashboard/src/dashboard/api/routes/pages.py
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
"""HTML pages served by Jinja2."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
|
||||
from fastapi.responses import HTMLResponse, Response
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import delete, desc, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import get_registry
|
||||
from dashboard.api.routes.config import KNOWN_KEYS, _coerce_and_validate
|
||||
from dashboard.db.models import (
|
||||
ArticlesArchive,
|
||||
AuditLog,
|
||||
ClaimArticle,
|
||||
ClaimsArchive,
|
||||
ConfigOverride,
|
||||
RequestHistory,
|
||||
)
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.providers.registry import ProviderRegistry
|
||||
|
||||
TEMPLATES_DIR = Path(__file__).parent.parent.parent / "templates"
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
async def overview(
|
||||
request: Request,
|
||||
registry: ProviderRegistry = Depends(get_registry),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
provider_stats = await registry.get_all()
|
||||
summary = await _get_summary(session, hours=24)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="overview.html",
|
||||
context={
|
||||
"providers": [s.to_dict() for s in provider_stats],
|
||||
"summary": summary,
|
||||
"active_page": "overview",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history", response_class=HTMLResponse)
|
||||
async def history_page(
|
||||
request: Request,
|
||||
tier: str | None = Query(default=None),
|
||||
provider: str | None = Query(default=None),
|
||||
endpoint: str | None = Query(default=None),
|
||||
hours: int = Query(default=168, ge=1, le=720),
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
q = select(RequestHistory).where(RequestHistory.created_at >= since)
|
||||
if tier:
|
||||
q = q.where(RequestHistory.tier == tier)
|
||||
if provider:
|
||||
q = q.where(RequestHistory.provider == provider)
|
||||
if endpoint:
|
||||
q = q.where(RequestHistory.endpoint == endpoint)
|
||||
q = q.order_by(desc(RequestHistory.created_at)).limit(limit)
|
||||
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="history.html",
|
||||
context={
|
||||
"items": rows,
|
||||
"filters": {
|
||||
"tier": tier or "",
|
||||
"provider": provider or "",
|
||||
"endpoint": endpoint or "",
|
||||
"hours": hours,
|
||||
},
|
||||
"active_page": "history",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/history/{request_id}", response_class=HTMLResponse)
|
||||
async def history_detail(
|
||||
request: Request,
|
||||
request_id: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
q = select(RequestHistory).where(RequestHistory.request_id == request_id)
|
||||
row = (await session.execute(q)).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail="Not found")
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="history_detail.html",
|
||||
context={
|
||||
"item": row,
|
||||
"active_page": "history",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/providers", response_class=HTMLResponse)
|
||||
async def providers_page(
|
||||
request: Request,
|
||||
registry: ProviderRegistry = Depends(get_registry),
|
||||
) -> HTMLResponse:
|
||||
stats = await registry.get_all(force=True)
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="providers.html",
|
||||
context={
|
||||
"providers": [s.to_dict() for s in stats],
|
||||
"active_page": "providers",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/cost", response_class=HTMLResponse)
|
||||
async def cost_page(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
registry: ProviderRegistry = Depends(get_registry),
|
||||
) -> HTMLResponse:
|
||||
"""Cost dashboard — spend breakdowns + projections."""
|
||||
now = datetime.now(timezone.utc)
|
||||
since_24h = now - timedelta(hours=24)
|
||||
since_7d = now - timedelta(days=7)
|
||||
since_30d = now - timedelta(days=30)
|
||||
|
||||
async def _sum_cost(since: datetime) -> float:
|
||||
q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
return float((await session.execute(q)).scalar() or 0)
|
||||
|
||||
cost_24h = await _sum_cost(since_24h)
|
||||
cost_7d = await _sum_cost(since_7d)
|
||||
cost_30d = await _sum_cost(since_30d)
|
||||
|
||||
# Projected monthly based on last 24h burn rate
|
||||
projected_monthly = round(cost_24h * 30, 2)
|
||||
|
||||
# Cost by provider (30d)
|
||||
provider_q = (
|
||||
select(
|
||||
RequestHistory.provider,
|
||||
func.count(RequestHistory.id).label("count"),
|
||||
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||
)
|
||||
.where(
|
||||
RequestHistory.created_at >= since_30d,
|
||||
RequestHistory.provider.is_not(None),
|
||||
)
|
||||
.group_by(RequestHistory.provider)
|
||||
.order_by(desc("cost"))
|
||||
)
|
||||
by_provider = [
|
||||
{"provider": row.provider, "count": row.count, "cost": float(row.cost or 0)}
|
||||
for row in (await session.execute(provider_q)).all()
|
||||
]
|
||||
|
||||
# Cost by tier (30d)
|
||||
tier_q = (
|
||||
select(
|
||||
RequestHistory.tier,
|
||||
func.count(RequestHistory.id).label("count"),
|
||||
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||
)
|
||||
.where(RequestHistory.created_at >= since_30d)
|
||||
.group_by(RequestHistory.tier)
|
||||
)
|
||||
by_tier = [
|
||||
{"tier": row.tier, "count": row.count, "cost": float(row.cost or 0)}
|
||||
for row in (await session.execute(tier_q)).all()
|
||||
]
|
||||
|
||||
# Top 10 most expensive requests
|
||||
top_q = (
|
||||
select(RequestHistory)
|
||||
.where(
|
||||
RequestHistory.created_at >= since_30d,
|
||||
RequestHistory.cost_usd.is_not(None),
|
||||
)
|
||||
.order_by(desc(RequestHistory.cost_usd))
|
||||
.limit(10)
|
||||
)
|
||||
top = (await session.execute(top_q)).scalars().all()
|
||||
|
||||
# Provider quota vs budget
|
||||
provider_stats = await registry.get_all()
|
||||
budget_bars = []
|
||||
for p in provider_stats:
|
||||
if p.quota_limit:
|
||||
budget_bars.append(
|
||||
{
|
||||
"name": p.display_name,
|
||||
"kind": p.kind,
|
||||
"used": p.quota_used or 0,
|
||||
"limit": p.quota_limit,
|
||||
"percent": p.quota_percent_used or 0,
|
||||
"unit": p.quota_unit,
|
||||
"plan_price": p.plan_price_monthly,
|
||||
}
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="cost.html",
|
||||
context={
|
||||
"cost_24h": round(cost_24h, 4),
|
||||
"cost_7d": round(cost_7d, 4),
|
||||
"cost_30d": round(cost_30d, 4),
|
||||
"projected_monthly": projected_monthly,
|
||||
"by_provider": by_provider,
|
||||
"by_tier": by_tier,
|
||||
"top": top,
|
||||
"budgets": budget_bars,
|
||||
"active_page": "cost",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/archive", response_class=HTMLResponse)
|
||||
async def archive_page(
|
||||
request: Request,
|
||||
q: str | None = Query(default=None),
|
||||
limit: int = Query(default=50, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
"""Browse archived claims."""
|
||||
stmt = select(ClaimsArchive)
|
||||
if q:
|
||||
stmt = stmt.where(ClaimsArchive.claim.ilike(f"%{q}%"))
|
||||
stmt = stmt.order_by(desc(ClaimsArchive.created_at)).limit(limit)
|
||||
|
||||
claims = (await session.execute(stmt)).scalars().all()
|
||||
total = (
|
||||
await session.execute(select(func.count(ClaimsArchive.id)))
|
||||
).scalar() or 0
|
||||
articles_total = (
|
||||
await session.execute(select(func.count(ArticlesArchive.id)))
|
||||
).scalar() or 0
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="archive.html",
|
||||
context={
|
||||
"claims": claims,
|
||||
"query": q or "",
|
||||
"total_claims": total,
|
||||
"total_articles": articles_total,
|
||||
"active_page": "archive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/archive/{claim_id}", response_class=HTMLResponse)
|
||||
async def archive_detail(
|
||||
request: Request,
|
||||
claim_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
"""Detail of a single archived claim with its articles."""
|
||||
claim = (
|
||||
await session.execute(select(ClaimsArchive).where(ClaimsArchive.id == claim_id))
|
||||
).scalar_one_or_none()
|
||||
if claim is None:
|
||||
raise HTTPException(status_code=404)
|
||||
|
||||
links_stmt = (
|
||||
select(ClaimArticle, ArticlesArchive)
|
||||
.join(ArticlesArchive, ClaimArticle.article_id == ArticlesArchive.id)
|
||||
.where(ClaimArticle.claim_id == claim_id)
|
||||
)
|
||||
links = (await session.execute(links_stmt)).all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="archive_detail.html",
|
||||
context={
|
||||
"claim": claim,
|
||||
"articles": [
|
||||
{"article": article, "link": link} for link, article in links
|
||||
],
|
||||
"active_page": "archive",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/audit", response_class=HTMLResponse)
|
||||
async def audit_page(
|
||||
request: Request,
|
||||
limit: int = Query(default=100, ge=1, le=500),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
"""Audit log page — shows who changed what, when."""
|
||||
q = select(AuditLog).order_by(desc(AuditLog.timestamp)).limit(limit)
|
||||
rows = (await session.execute(q)).scalars().all()
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="audit.html",
|
||||
context={"entries": rows, "active_page": "audit"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/config", response_class=HTMLResponse)
|
||||
async def config_page(
|
||||
request: Request,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> HTMLResponse:
|
||||
"""Runtime configuration page — toggle providers, strategy, models."""
|
||||
overrides_rows = (await session.execute(select(ConfigOverride))).scalars().all()
|
||||
overrides = {r.key: r for r in overrides_rows}
|
||||
|
||||
items = []
|
||||
categories: dict[str, list[dict]] = {
|
||||
"providers": [],
|
||||
"routing": [],
|
||||
"llm": [],
|
||||
"tiers": [],
|
||||
}
|
||||
for key, meta in KNOWN_KEYS.items():
|
||||
row = overrides.get(key)
|
||||
entry = {
|
||||
"key": key,
|
||||
"meta": meta,
|
||||
"value": row.value if row else meta["default"],
|
||||
"is_override": row is not None,
|
||||
"updated_at": row.updated_at if row else None,
|
||||
"updated_by": row.updated_by if row else None,
|
||||
}
|
||||
items.append(entry)
|
||||
categories.setdefault(meta.get("category", "other"), []).append(entry)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="config.html",
|
||||
context={
|
||||
"categories": categories,
|
||||
"active_page": "config",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config/{key}", response_class=HTMLResponse)
|
||||
async def config_update(
|
||||
request: Request,
|
||||
key: str,
|
||||
value: str = Form(...),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
"""HTMX form handler — update a config key and return the row fragment."""
|
||||
if key not in KNOWN_KEYS:
|
||||
raise HTTPException(status_code=404, detail="Unknown key")
|
||||
|
||||
meta = KNOWN_KEYS[key]
|
||||
coerced = _coerce_and_validate(key, value, meta)
|
||||
|
||||
existing = (
|
||||
await session.execute(select(ConfigOverride).where(ConfigOverride.key == key))
|
||||
).scalar_one_or_none()
|
||||
|
||||
old_value = existing.value if existing else meta["default"]
|
||||
username = request.headers.get("X-User", "dashboard-ui")
|
||||
|
||||
if existing is None:
|
||||
session.add(
|
||||
ConfigOverride(
|
||||
key=key,
|
||||
value=coerced,
|
||||
updated_by=username,
|
||||
description=meta.get("description"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.value = coerced
|
||||
existing.updated_by = username
|
||||
existing.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
session.add(
|
||||
AuditLog(
|
||||
username=username,
|
||||
action="config.set",
|
||||
target=key,
|
||||
old_value=old_value,
|
||||
new_value=coerced,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
entry = {
|
||||
"key": key,
|
||||
"meta": meta,
|
||||
"value": coerced,
|
||||
"is_override": True,
|
||||
"updated_at": datetime.now(timezone.utc),
|
||||
"updated_by": username,
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/config_row.html",
|
||||
context={"entry": entry},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/config/{key}/reset", response_class=HTMLResponse)
|
||||
async def config_reset(
|
||||
request: Request,
|
||||
key: str,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> Response:
|
||||
"""HTMX — revert a config key to its default."""
|
||||
if key not in KNOWN_KEYS:
|
||||
raise HTTPException(status_code=404, detail="Unknown key")
|
||||
|
||||
existing = (
|
||||
await session.execute(select(ConfigOverride).where(ConfigOverride.key == key))
|
||||
).scalar_one_or_none()
|
||||
old_value = existing.value if existing else None
|
||||
|
||||
if existing is not None:
|
||||
await session.execute(
|
||||
delete(ConfigOverride).where(ConfigOverride.key == key)
|
||||
)
|
||||
session.add(
|
||||
AuditLog(
|
||||
username=request.headers.get("X-User", "dashboard-ui"),
|
||||
action="config.reset",
|
||||
target=key,
|
||||
old_value=old_value,
|
||||
new_value=None,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
meta = KNOWN_KEYS[key]
|
||||
entry = {
|
||||
"key": key,
|
||||
"meta": meta,
|
||||
"value": meta["default"],
|
||||
"is_override": False,
|
||||
"updated_at": None,
|
||||
"updated_by": None,
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
name="partials/config_row.html",
|
||||
context={"entry": entry},
|
||||
)
|
||||
|
||||
|
||||
async def _get_summary(session: AsyncSession, hours: int = 24) -> dict:
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
|
||||
total_q = select(func.count(RequestHistory.id)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
total = (await session.execute(total_q)).scalar() or 0
|
||||
|
||||
tier_q = (
|
||||
select(RequestHistory.tier, func.count(RequestHistory.id))
|
||||
.where(RequestHistory.created_at >= since)
|
||||
.group_by(RequestHistory.tier)
|
||||
)
|
||||
tiers = dict((await session.execute(tier_q)).all())
|
||||
|
||||
err_q = select(func.count(RequestHistory.id)).where(
|
||||
RequestHistory.created_at >= since,
|
||||
RequestHistory.status_code >= 400,
|
||||
)
|
||||
errors = (await session.execute(err_q)).scalar() or 0
|
||||
|
||||
avg_q = select(func.avg(RequestHistory.duration_ms)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
avg_duration = float((await session.execute(avg_q)).scalar() or 0)
|
||||
|
||||
cost_q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
total_cost = float((await session.execute(cost_q)).scalar() or 0)
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"errors": errors,
|
||||
"error_rate": round((errors / total * 100), 1) if total else 0.0,
|
||||
"avg_duration_ms": round(avg_duration, 0),
|
||||
"total_cost": round(total_cost, 4),
|
||||
"by_tier": {k or "unknown": v for k, v in tiers.items()},
|
||||
"hours": hours,
|
||||
}
|
||||
100
ai_platform/modules/dashboard/src/dashboard/api/routes/proxy.py
Normal file
100
ai_platform/modules/dashboard/src/dashboard/api/routes/proxy.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Module health proxy — pings each module's health endpoint, returns normalized status."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
|
||||
from dashboard.api.dependencies import verify_bearer_token
|
||||
from dashboard.config import SettingsCache
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("proxy")
|
||||
# RBAC: protected when auth is configured; open in dev when not.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
|
||||
# Module → health endpoint URL.
|
||||
# Reads optional env overrides first; falls back to settings or known defaults.
|
||||
# Set DASHBOARD_<module>_HEALTH_URL to override (e.g. DASHBOARD_EMBEDDINGS_HEALTH_URL=http://10.11.10.17:14100).
|
||||
def _module_endpoints() -> dict[str, str]:
|
||||
s = SettingsCache.get()
|
||||
|
||||
def envurl(key: str, default: str) -> str:
|
||||
return os.environ.get(f"DASHBOARD_{key.upper()}_HEALTH_URL", default)
|
||||
|
||||
return {
|
||||
"web": envurl("web", f"{s.web_api_url}/v1/info"),
|
||||
"llm": envurl("llm", f"{s.llm_api_url}/v1/info"),
|
||||
"embeddings": envurl("embeddings", "http://10.11.10.17:14100/v1/info"),
|
||||
"rerank": envurl("rerank", "http://10.11.10.17:14200/v1/info"),
|
||||
"audio": envurl("audio", "http://10.11.10.17:54300/v1/info"),
|
||||
"video": envurl("video", "http://10.11.10.17:54600/v1/info"),
|
||||
"catalog": envurl("catalog", "http://10.11.10.17:11000/v1/info"),
|
||||
"gateway": envurl("gateway", "http://10.11.10.17:11000/v1/info"),
|
||||
"brain": envurl("brain", f"{s.brain_url}/v1/info"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/proxy/{module_id}/health")
|
||||
async def module_health(module_id: str) -> dict[str, Any]:
|
||||
"""Ping a module's health endpoint. Normalized response: status, latency, error."""
|
||||
endpoints = _module_endpoints()
|
||||
if module_id not in endpoints:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown module: {module_id}")
|
||||
|
||||
url = endpoints[module_id]
|
||||
started = time.monotonic()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
r = await client.get(url)
|
||||
latency_ms = round((time.monotonic() - started) * 1000, 1)
|
||||
if r.status_code >= 500:
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "down",
|
||||
"http_status": r.status_code,
|
||||
"latency_ms": latency_ms,
|
||||
"error": r.text[:200] if r.text else None,
|
||||
}
|
||||
if r.status_code >= 400:
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "degraded",
|
||||
"http_status": r.status_code,
|
||||
"latency_ms": latency_ms,
|
||||
"error": r.text[:200] if r.text else None,
|
||||
}
|
||||
body: Any = None
|
||||
try:
|
||||
body = r.json()
|
||||
except Exception: # noqa: BLE001 — broad on purpose, body is best-effort
|
||||
body = None
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "healthy",
|
||||
"http_status": r.status_code,
|
||||
"latency_ms": latency_ms,
|
||||
"info": body,
|
||||
}
|
||||
except httpx.TimeoutException:
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "down",
|
||||
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
"error": "timeout (>3s)",
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
return {
|
||||
"module": module_id,
|
||||
"url": url,
|
||||
"status": "down",
|
||||
"latency_ms": round((time.monotonic() - started) * 1000, 1),
|
||||
"error": str(e)[:200],
|
||||
}
|
||||
300
ai_platform/modules/dashboard/src/dashboard/api/routes/stats.py
Normal file
300
ai_platform/modules/dashboard/src/dashboard/api/routes/stats.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
"""Live stats endpoints."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.api.dependencies import get_registry, verify_bearer_token
|
||||
from dashboard.db.models import RequestHistory
|
||||
from dashboard.db.session import get_session
|
||||
from dashboard.providers.registry import ProviderRegistry
|
||||
|
||||
# RBAC: protected when auth is configured (Keycloak/tokens); open in dev when not.
|
||||
router = APIRouter(dependencies=[Depends(verify_bearer_token)])
|
||||
|
||||
|
||||
@router.get("/stats/providers")
|
||||
async def providers_stats(
|
||||
force: bool = Query(default=False),
|
||||
registry: ProviderRegistry = Depends(get_registry),
|
||||
) -> dict:
|
||||
"""Return live stats for all providers (cached)."""
|
||||
stats = await registry.get_all(force=force)
|
||||
return {
|
||||
"providers": [s.to_dict() for s in stats],
|
||||
"last_refresh": registry.last_refresh_iso,
|
||||
"age_seconds": registry.age_seconds,
|
||||
"cache_ttl_seconds": registry.settings.provider_stats_cache_seconds,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/summary")
|
||||
async def summary_stats(
|
||||
hours: int = Query(default=24, ge=1, le=720),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Aggregated counters over the last N hours."""
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
|
||||
# Total requests
|
||||
total_q = select(func.count(RequestHistory.id)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
total = (await session.execute(total_q)).scalar() or 0
|
||||
|
||||
# By tier
|
||||
tier_q = (
|
||||
select(RequestHistory.tier, func.count(RequestHistory.id))
|
||||
.where(RequestHistory.created_at >= since)
|
||||
.group_by(RequestHistory.tier)
|
||||
)
|
||||
tiers = dict((await session.execute(tier_q)).all())
|
||||
|
||||
# By provider
|
||||
provider_q = (
|
||||
select(RequestHistory.provider, func.count(RequestHistory.id))
|
||||
.where(
|
||||
RequestHistory.created_at >= since,
|
||||
RequestHistory.provider.is_not(None),
|
||||
)
|
||||
.group_by(RequestHistory.provider)
|
||||
)
|
||||
providers = dict((await session.execute(provider_q)).all())
|
||||
|
||||
# By endpoint
|
||||
endpoint_q = (
|
||||
select(RequestHistory.endpoint, func.count(RequestHistory.id))
|
||||
.where(RequestHistory.created_at >= since)
|
||||
.group_by(RequestHistory.endpoint)
|
||||
)
|
||||
endpoints = dict((await session.execute(endpoint_q)).all())
|
||||
|
||||
# Avg duration
|
||||
avg_q = select(func.avg(RequestHistory.duration_ms)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
avg_duration = (await session.execute(avg_q)).scalar()
|
||||
|
||||
# Error rate
|
||||
err_q = select(func.count(RequestHistory.id)).where(
|
||||
RequestHistory.created_at >= since,
|
||||
RequestHistory.status_code >= 400,
|
||||
)
|
||||
errors = (await session.execute(err_q)).scalar() or 0
|
||||
|
||||
# Total cost
|
||||
cost_q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
total_cost = float((await session.execute(cost_q)).scalar() or 0)
|
||||
|
||||
return {
|
||||
"window_hours": hours,
|
||||
"total_requests": total,
|
||||
"total_errors": errors,
|
||||
"error_rate": round(errors / total, 4) if total else 0,
|
||||
"avg_duration_ms": round(float(avg_duration or 0), 2),
|
||||
"total_cost_usd": round(total_cost, 4),
|
||||
"by_tier": tiers,
|
||||
"by_provider": providers,
|
||||
"by_endpoint": endpoints,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/cost")
|
||||
async def cost_stats(
|
||||
session: AsyncSession = Depends(get_session),
|
||||
registry: ProviderRegistry = Depends(get_registry),
|
||||
) -> dict:
|
||||
"""Cost rollups (24h / 7d / 30d) + breakdowns + budget bars."""
|
||||
from sqlalchemy import desc
|
||||
|
||||
from dashboard.db.models import RequestHistory
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
since_24h = now - timedelta(hours=24)
|
||||
since_7d = now - timedelta(days=7)
|
||||
since_30d = now - timedelta(days=30)
|
||||
|
||||
async def _sum(since: datetime) -> float:
|
||||
q = select(func.coalesce(func.sum(RequestHistory.cost_usd), 0)).where(
|
||||
RequestHistory.created_at >= since
|
||||
)
|
||||
return float((await session.execute(q)).scalar() or 0)
|
||||
|
||||
cost_24h = await _sum(since_24h)
|
||||
cost_7d = await _sum(since_7d)
|
||||
cost_30d = await _sum(since_30d)
|
||||
|
||||
# By provider (30d) — only paid providers (cost > 0). Brain/agent_v3 emit
|
||||
# rows with cost=0 (no upstream provider), they would otherwise clutter the
|
||||
# chart with empty bars labelled "v3", "get", etc.
|
||||
prov_q = (
|
||||
select(
|
||||
RequestHistory.provider,
|
||||
func.count(RequestHistory.id).label("count"),
|
||||
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||
)
|
||||
.where(
|
||||
RequestHistory.created_at >= since_30d,
|
||||
RequestHistory.provider.is_not(None),
|
||||
)
|
||||
.group_by(RequestHistory.provider)
|
||||
.having(func.coalesce(func.sum(RequestHistory.cost_usd), 0) > 0)
|
||||
.order_by(desc("cost"))
|
||||
)
|
||||
by_provider = [
|
||||
{"provider": r.provider, "count": r.count, "cost": float(r.cost or 0)}
|
||||
for r in (await session.execute(prov_q)).all()
|
||||
]
|
||||
|
||||
# By tier (30d)
|
||||
tier_q = (
|
||||
select(
|
||||
RequestHistory.tier,
|
||||
func.count(RequestHistory.id).label("count"),
|
||||
func.coalesce(func.sum(RequestHistory.cost_usd), 0).label("cost"),
|
||||
)
|
||||
.where(RequestHistory.created_at >= since_30d)
|
||||
.group_by(RequestHistory.tier)
|
||||
)
|
||||
by_tier = [
|
||||
{"tier": r.tier, "count": r.count, "cost": float(r.cost or 0)}
|
||||
for r in (await session.execute(tier_q)).all()
|
||||
]
|
||||
|
||||
# Top expensive (30d) — exclude $0 rows. "Most expensive" of free/zero-cost
|
||||
# rows is meaningless and crowds the table with brain/agent_v3 entries.
|
||||
top_q = (
|
||||
select(
|
||||
RequestHistory.id,
|
||||
RequestHistory.created_at,
|
||||
RequestHistory.tier,
|
||||
RequestHistory.provider,
|
||||
RequestHistory.endpoint,
|
||||
RequestHistory.cost_usd,
|
||||
RequestHistory.duration_ms,
|
||||
)
|
||||
.where(
|
||||
RequestHistory.created_at >= since_30d,
|
||||
RequestHistory.cost_usd.is_not(None),
|
||||
RequestHistory.cost_usd > 0,
|
||||
)
|
||||
.order_by(desc(RequestHistory.cost_usd))
|
||||
.limit(10)
|
||||
)
|
||||
top = [
|
||||
{
|
||||
"id": r.id,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"tier": r.tier,
|
||||
"provider": r.provider,
|
||||
"endpoint": r.endpoint,
|
||||
"cost": float(r.cost_usd or 0),
|
||||
"duration_ms": r.duration_ms,
|
||||
}
|
||||
for r in (await session.execute(top_q)).all()
|
||||
]
|
||||
|
||||
# Budget bars from live provider stats
|
||||
provider_stats = await registry.get_all()
|
||||
budgets = []
|
||||
for p in provider_stats:
|
||||
if p.quota_limit:
|
||||
budgets.append(
|
||||
{
|
||||
"name": p.display_name,
|
||||
"kind": p.kind,
|
||||
"used": p.quota_used or 0,
|
||||
"limit": p.quota_limit,
|
||||
"percent": p.quota_percent_used or 0,
|
||||
"unit": p.quota_unit,
|
||||
"plan_price": p.plan_price_monthly,
|
||||
}
|
||||
)
|
||||
|
||||
# Smart projection: weighted blend of 7d and 30d daily averages.
|
||||
# If we have 30d of data, prefer that average × 30. Else 7d × 30/7. Fallback to 24h × 30.
|
||||
daily_30d = cost_30d / 30 if cost_30d > 0 else 0
|
||||
daily_7d = cost_7d / 7 if cost_7d > 0 else 0
|
||||
daily_24h = cost_24h
|
||||
if daily_30d > 0 and daily_7d > 0:
|
||||
# Weighted: 30d gives stability, 7d catches recent shifts
|
||||
daily_blend = (daily_30d * 0.4) + (daily_7d * 0.6)
|
||||
confidence = "stable"
|
||||
basis = "blend(30d=40%, 7d=60%)"
|
||||
elif daily_7d > 0:
|
||||
daily_blend = daily_7d
|
||||
confidence = "moderate"
|
||||
basis = "7d_avg"
|
||||
else:
|
||||
daily_blend = daily_24h
|
||||
confidence = "rough"
|
||||
basis = "24h_only"
|
||||
projected_monthly = round(daily_blend * 30, 2)
|
||||
|
||||
# Trend: last 7d vs previous 7d → "increasing", "decreasing", "stable"
|
||||
cost_7d_prev = await _sum(now - timedelta(days=14)) - cost_7d
|
||||
if cost_7d > 0 and cost_7d_prev > 0:
|
||||
trend_pct = ((cost_7d - cost_7d_prev) / cost_7d_prev) * 100
|
||||
if trend_pct > 10:
|
||||
trend = "increasing"
|
||||
elif trend_pct < -10:
|
||||
trend = "decreasing"
|
||||
else:
|
||||
trend = "stable"
|
||||
trend_pct = round(trend_pct, 1)
|
||||
else:
|
||||
trend = "unknown"
|
||||
trend_pct = None
|
||||
|
||||
return {
|
||||
"cost_24h": round(cost_24h, 4),
|
||||
"cost_7d": round(cost_7d, 4),
|
||||
"cost_30d": round(cost_30d, 4),
|
||||
"projected_monthly": projected_monthly,
|
||||
"projection_basis": basis,
|
||||
"projection_confidence": confidence,
|
||||
"trend": trend,
|
||||
"trend_pct": trend_pct,
|
||||
"by_provider": by_provider,
|
||||
"by_tier": by_tier,
|
||||
"top": top,
|
||||
"budgets": budgets,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/stats/timeline")
|
||||
async def timeline_stats(
|
||||
hours: int = Query(default=24, ge=1, le=720),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
) -> dict:
|
||||
"""Hourly request counts for charts."""
|
||||
since = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
|
||||
q = (
|
||||
select(
|
||||
func.date_trunc("hour", RequestHistory.created_at).label("hour"),
|
||||
RequestHistory.tier,
|
||||
func.count(RequestHistory.id).label("count"),
|
||||
func.avg(RequestHistory.duration_ms).label("avg_ms"),
|
||||
)
|
||||
.where(RequestHistory.created_at >= since)
|
||||
.group_by("hour", RequestHistory.tier)
|
||||
.order_by("hour")
|
||||
)
|
||||
rows = (await session.execute(q)).all()
|
||||
|
||||
buckets: list[dict] = []
|
||||
for row in rows:
|
||||
buckets.append(
|
||||
{
|
||||
"hour": row.hour.isoformat() if row.hour else None,
|
||||
"tier": row.tier,
|
||||
"count": row.count,
|
||||
"avg_ms": round(float(row.avg_ms or 0), 2),
|
||||
}
|
||||
)
|
||||
return {"timeline": buckets, "window_hours": hours}
|
||||
82
ai_platform/modules/dashboard/src/dashboard/auth.py
Normal file
82
ai_platform/modules/dashboard/src/dashboard/auth.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
"""User authentication — Bearer tokens mapped to named users in the DB.
|
||||
|
||||
Dashboard runs in an internal VPN so this is intentionally simple:
|
||||
a shared table of (username, token_hash) rows with a role column for
|
||||
future ACL expansion. Tokens are compared via constant-time hashing.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from dashboard.db.models import User
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("auth")
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""Hash a raw token with SHA-256 for storage."""
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_token() -> str:
|
||||
"""Generate a fresh URL-safe token (32 bytes of entropy)."""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
async def find_user_by_token(session: AsyncSession, token: str) -> User | None:
|
||||
"""Look up a user by raw token (hashes + constant-time compare).
|
||||
|
||||
Returns None when the token is unknown or the user is disabled.
|
||||
"""
|
||||
token_hash = hash_token(token)
|
||||
rows = (await session.execute(select(User))).scalars().all()
|
||||
for user in rows:
|
||||
if hmac.compare_digest(user.token_hash, token_hash):
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
async def upsert_user(
|
||||
session: AsyncSession,
|
||||
username: str,
|
||||
token: str,
|
||||
email: str | None = None,
|
||||
role: str = "admin",
|
||||
) -> User:
|
||||
"""Create or update a user with the given token."""
|
||||
existing = (
|
||||
await session.execute(select(User).where(User.username == username))
|
||||
).scalar_one_or_none()
|
||||
|
||||
token_hash = hash_token(token)
|
||||
|
||||
if existing is None:
|
||||
user = User(
|
||||
username=username,
|
||||
email=email,
|
||||
token_hash=token_hash,
|
||||
role=role,
|
||||
)
|
||||
session.add(user)
|
||||
logger.info("Created user %s (role=%s)", username, role)
|
||||
else:
|
||||
existing.token_hash = token_hash
|
||||
if email is not None:
|
||||
existing.email = email
|
||||
existing.role = role
|
||||
user = existing
|
||||
logger.info("Updated user %s (role=%s)", username, role)
|
||||
|
||||
await session.commit()
|
||||
return user
|
||||
|
||||
|
||||
async def record_login(session: AsyncSession, user: User) -> None:
|
||||
user.last_login = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
103
ai_platform/modules/dashboard/src/dashboard/cli.py
Normal file
103
ai_platform/modules/dashboard/src/dashboard/cli.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""CLI utilities for admin tasks (create user, list users, etc.).
|
||||
|
||||
Usage (inside the container):
|
||||
python -m dashboard.cli create-user didi
|
||||
python -m dashboard.cli list-users
|
||||
python -m dashboard.cli delete-user didi
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from dashboard.auth import generate_token, upsert_user
|
||||
from dashboard.config import SettingsCache
|
||||
from dashboard.db.models import User
|
||||
from dashboard.db.session import close_engine, get_session_factory, init_engine
|
||||
|
||||
|
||||
async def _create_user(username: str, email: str | None, role: str) -> None:
|
||||
settings = SettingsCache.get()
|
||||
init_engine(settings)
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
token = generate_token()
|
||||
user = await upsert_user(session, username, token, email=email, role=role)
|
||||
print("User created:")
|
||||
print(f" id: {user.id}")
|
||||
print(f" username: {user.username}")
|
||||
print(f" role: {user.role}")
|
||||
print(f" email: {user.email or '—'}")
|
||||
print()
|
||||
print("Bearer token (SAVE THIS — it is not shown again):")
|
||||
print(f" {token}")
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _list_users() -> None:
|
||||
settings = SettingsCache.get()
|
||||
init_engine(settings)
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
rows = (await session.execute(select(User).order_by(User.id))).scalars().all()
|
||||
if not rows:
|
||||
print("No users yet.")
|
||||
await close_engine()
|
||||
return
|
||||
print(f"{'ID':<4} {'USERNAME':<20} {'ROLE':<10} {'EMAIL':<30} LAST LOGIN")
|
||||
print("-" * 100)
|
||||
for u in rows:
|
||||
last = u.last_login.strftime("%Y-%m-%d %H:%M") if u.last_login else "never"
|
||||
print(
|
||||
f"{u.id:<4} {u.username:<20} {u.role:<10} {(u.email or '—'):<30} {last}"
|
||||
)
|
||||
await close_engine()
|
||||
|
||||
|
||||
async def _delete_user(username: str) -> None:
|
||||
settings = SettingsCache.get()
|
||||
init_engine(settings)
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
result = await session.execute(
|
||||
delete(User).where(User.username == username)
|
||||
)
|
||||
await session.commit()
|
||||
if result.rowcount:
|
||||
print(f"Deleted user {username}")
|
||||
else:
|
||||
print(f"User {username} not found")
|
||||
await close_engine()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(prog="dashboard.cli", description="Dashboard admin")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_create = sub.add_parser("create-user", help="Create or update a user")
|
||||
p_create.add_argument("username")
|
||||
p_create.add_argument("--email", default=None)
|
||||
p_create.add_argument("--role", default="admin", choices=["admin", "viewer"])
|
||||
|
||||
sub.add_parser("list-users", help="List all users")
|
||||
|
||||
p_delete = sub.add_parser("delete-user", help="Delete a user by username")
|
||||
p_delete.add_argument("username")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.command == "create-user":
|
||||
asyncio.run(_create_user(args.username, args.email, args.role))
|
||||
elif args.command == "list-users":
|
||||
asyncio.run(_list_users())
|
||||
elif args.command == "delete-user":
|
||||
asyncio.run(_delete_user(args.username))
|
||||
else:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
225
ai_platform/modules/dashboard/src/dashboard/config.py
Normal file
225
ai_platform/modules/dashboard/src/dashboard/config.py
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
"""Dashboard module configuration."""
|
||||
|
||||
import threading
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
|
||||
class DashboardSettings(BaseSettings):
|
||||
"""Dashboard settings loaded from environment.
|
||||
|
||||
All variables use the DASHBOARD_ prefix.
|
||||
"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="DASHBOARD_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
extra="forbid",
|
||||
env_ignore_empty=True,
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# REQUIRED - Database
|
||||
# ==========================================================================
|
||||
|
||||
database_url: str = Field(
|
||||
description="PostgreSQL async URL (e.g., postgresql+asyncpg://user:pass@host:5432/db)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# REQUIRED - Server
|
||||
# ==========================================================================
|
||||
|
||||
host: str = Field(default="0.0.0.0")
|
||||
port: int = Field(default=51300)
|
||||
external_url: str = Field(
|
||||
description="External URL for OpenAPI (e.g., http://localhost:51300)",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# OPTIONAL - Provider API keys (for live quota fetching)
|
||||
# ==========================================================================
|
||||
|
||||
serpapi_api_key: str | None = Field(default=None)
|
||||
tavily_api_key: str | None = Field(default=None)
|
||||
exa_api_key: str | None = Field(default=None)
|
||||
linkup_api_key: str | None = Field(default=None)
|
||||
brave_api_key: str | None = Field(default=None)
|
||||
openrouter_api_key: str | None = Field(default=None)
|
||||
|
||||
# ==========================================================================
|
||||
# OPTIONAL - Upstream service URLs for health checks
|
||||
# ==========================================================================
|
||||
|
||||
web_api_url: str = Field(
|
||||
default="http://didiAI-web-api:51100",
|
||||
description="Web API base URL for health checks",
|
||||
)
|
||||
searxng_url: str = Field(
|
||||
default="http://didiAI-web-searxng:8080",
|
||||
description="SearXNG base URL for health check",
|
||||
)
|
||||
llm_api_url: str = Field(
|
||||
default="http://didiAI-llm-api:14011",
|
||||
description="LLM Inference API base URL",
|
||||
)
|
||||
vllm_qwen_url: str = Field(
|
||||
default="http://didiAI-vllm-qwen3.5:14001",
|
||||
description="Local vLLM Qwen base URL",
|
||||
)
|
||||
llamacpp_urls: Annotated[list[str], NoDecode] = Field(
|
||||
default_factory=lambda: [
|
||||
"http://10.11.10.18:14001",
|
||||
"http://10.11.10.19:14001",
|
||||
],
|
||||
description="Comma-separated list of llama.cpp server URLs",
|
||||
)
|
||||
brain_url: str = Field(
|
||||
default="http://didibrain-api:8090",
|
||||
description="didi-brain base URL — proxied via /api/brain/* for the SPA",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Observability sources (AI monitoring panel — Val 1)
|
||||
# ==========================================================================
|
||||
|
||||
prometheus_url: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Prometheus base URL (e.g. http://prometheus:9090). Empty disables the "
|
||||
"latency-percentiles panel; health/queues still work."
|
||||
),
|
||||
)
|
||||
rabbitmq_mgmt_url: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"RabbitMQ management API base URL (e.g. "
|
||||
"http://staging-dataLayer-rabbitmq:15672). Empty disables the queue panel."
|
||||
),
|
||||
)
|
||||
rabbitmq_mgmt_user: str = Field(
|
||||
default="guest", description="RabbitMQ management API user"
|
||||
)
|
||||
rabbitmq_mgmt_password: str = Field(
|
||||
default="guest", description="RabbitMQ management API password"
|
||||
)
|
||||
|
||||
@field_validator("llamacpp_urls", mode="before")
|
||||
@classmethod
|
||||
def parse_llamacpp_urls(cls, v: str | list[str] | None) -> list[str]:
|
||||
if v is None or v == "":
|
||||
return []
|
||||
if isinstance(v, str):
|
||||
return [u.strip() for u in v.split(",") if u.strip()]
|
||||
return list(v)
|
||||
|
||||
# ==========================================================================
|
||||
# History & retention
|
||||
# ==========================================================================
|
||||
|
||||
history_retention_days: int = Field(
|
||||
default=30,
|
||||
ge=1,
|
||||
le=365,
|
||||
description="How many days of request history to keep",
|
||||
)
|
||||
provider_stats_cache_seconds: int = Field(
|
||||
default=30,
|
||||
ge=5,
|
||||
le=3600,
|
||||
description="Cache duration for live provider stats",
|
||||
)
|
||||
|
||||
# ==========================================================================
|
||||
# Auth — bearer token (legacy) + Keycloak JWT (preferred)
|
||||
# ==========================================================================
|
||||
|
||||
api_tokens: Annotated[frozenset[str] | None, NoDecode] = Field(
|
||||
default=None,
|
||||
description="Bearer tokens for dashboard API (comma-separated)",
|
||||
)
|
||||
|
||||
# Keycloak SSO. When `keycloak_url` is set, JWT validation is enabled
|
||||
# alongside the legacy bearer token check. JWTs win first; if absent, the
|
||||
# request falls back to the legacy bearer dance.
|
||||
keycloak_url: str | None = Field(
|
||||
default=None,
|
||||
description="Keycloak base URL (e.g., https://sso.clossers.com). Empty disables JWT auth.",
|
||||
)
|
||||
keycloak_realm: str = Field(
|
||||
default="didi-clients",
|
||||
description="Keycloak realm",
|
||||
)
|
||||
keycloak_client_id: str = Field(
|
||||
default="ai-platform-dashboard",
|
||||
description="Keycloak client_id (audience claim) for this dashboard",
|
||||
)
|
||||
keycloak_required_role: str = Field(
|
||||
default="admin",
|
||||
description=(
|
||||
"Realm role required to access this dashboard. Default `admin` matches "
|
||||
"the same role used by DIDI admin-dashboard, so a single Keycloak admin "
|
||||
"role grants access to both. Override via env if you want a separate role."
|
||||
),
|
||||
)
|
||||
staging_mode: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When true, all auth checks pass — for local dev only. "
|
||||
"MUST be false in production."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("api_tokens", mode="before")
|
||||
@classmethod
|
||||
def parse_api_tokens(cls, v: str | list[str] | None) -> frozenset[str] | None:
|
||||
if v is None or v == "":
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
tokens = [t.strip() for t in v.split(",") if t.strip()]
|
||||
return frozenset(tokens) if tokens else None
|
||||
return frozenset(v) if v else None
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
return bool(self.api_tokens)
|
||||
|
||||
# ==========================================================================
|
||||
# Observability
|
||||
# ==========================================================================
|
||||
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(
|
||||
default="INFO"
|
||||
)
|
||||
log_json: bool = Field(default=False)
|
||||
|
||||
|
||||
class SettingsCache:
|
||||
"""Thread-safe settings cache."""
|
||||
|
||||
_instance: DashboardSettings | None = None
|
||||
_lock: threading.Lock = threading.Lock()
|
||||
|
||||
@classmethod
|
||||
def get(cls) -> DashboardSettings:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = DashboardSettings()
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def clear(cls) -> None:
|
||||
with cls._lock:
|
||||
cls._instance = None
|
||||
|
||||
@classmethod
|
||||
def set(cls, settings: DashboardSettings) -> None:
|
||||
with cls._lock:
|
||||
cls._instance = settings
|
||||
|
||||
|
||||
def get_settings() -> DashboardSettings:
|
||||
return SettingsCache.get()
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Database layer."""
|
||||
291
ai_platform/modules/dashboard/src/dashboard/db/models.py
Normal file
291
ai_platform/modules/dashboard/src/dashboard/db/models.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
"""SQLAlchemy models."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
Numeric,
|
||||
String,
|
||||
Text,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Base class for all models."""
|
||||
|
||||
|
||||
class RequestHistory(Base):
|
||||
"""Individual request log (30-day rolling)."""
|
||||
|
||||
__tablename__ = "request_history"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
request_id: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
# Request metadata
|
||||
# `module` distinguishes which AI platform service emitted the event:
|
||||
# web — fact-checking module (DashboardEventSink in modules/web/)
|
||||
# brain — knowledge graph + cache module (modules/didi_brain/)
|
||||
# agent_v3 — backend orchestrator (TypeScript)
|
||||
# `tier` is web-flavored (free/premium) — for non-web modules it stays "n/a".
|
||||
module: Mapped[str] = mapped_column(String(16), nullable=False, default="web")
|
||||
tier: Mapped[str] = mapped_column(String(16), nullable=False, default="free")
|
||||
endpoint: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
provider: Mapped[str | None] = mapped_column(String(32))
|
||||
|
||||
# Query content
|
||||
claim: Mapped[str | None] = mapped_column(Text)
|
||||
query: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# Timing
|
||||
duration_ms: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
# Results
|
||||
status_code: Mapped[int | None] = mapped_column(Integer)
|
||||
results_count: Mapped[int | None] = mapped_column(Integer)
|
||||
evidence_count: Mapped[int | None] = mapped_column(Integer)
|
||||
error: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# Costs
|
||||
cost_usd: Mapped[float | None] = mapped_column(Numeric(12, 6))
|
||||
|
||||
# User tracking (from backend)
|
||||
user_id: Mapped[str | None] = mapped_column(String(128))
|
||||
|
||||
# Stages breakdown (for gather endpoint)
|
||||
stages: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||
|
||||
# Raw data (trimmed)
|
||||
raw_request: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||
raw_response: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_history_created", created_at.desc()),
|
||||
Index("ix_history_tier_created", tier, created_at.desc()),
|
||||
Index("ix_history_provider", provider, created_at.desc()),
|
||||
Index("ix_history_endpoint", endpoint, created_at.desc()),
|
||||
Index("ix_history_module_created", module, created_at.desc()),
|
||||
)
|
||||
|
||||
|
||||
class ProviderStatsHourly(Base):
|
||||
"""Hourly rollups for fast dashboard queries."""
|
||||
|
||||
__tablename__ = "provider_stats_hourly"
|
||||
|
||||
provider: Mapped[str] = mapped_column(String(32), primary_key=True)
|
||||
hour: Mapped[datetime] = mapped_column(DateTime(timezone=True), primary_key=True)
|
||||
|
||||
request_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
error_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
avg_duration_ms: Mapped[float] = mapped_column(
|
||||
Numeric(12, 2), default=0, nullable=False
|
||||
)
|
||||
total_cost_usd: Mapped[float] = mapped_column(
|
||||
Numeric(12, 6), default=0, nullable=False
|
||||
)
|
||||
|
||||
|
||||
class ConfigOverride(Base):
|
||||
"""Runtime configuration overrides."""
|
||||
|
||||
__tablename__ = "config_overrides"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
value: Mapped[Any] = mapped_column(JSON, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(128))
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
|
||||
class ConfigSchemaOverride(Base):
|
||||
"""Runtime schema entries — augments hardcoded KNOWN_KEYS without redeploy.
|
||||
|
||||
Each row represents a config key registered at runtime. The metadata JSON
|
||||
matches the same shape as hardcoded entries: type, default, module,
|
||||
category, label, description, restart_required, plus optional min/max/options.
|
||||
"""
|
||||
|
||||
__tablename__ = "config_schema_override"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(128), primary_key=True)
|
||||
metadata_json: Mapped[Any] = mapped_column(JSON, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
created_by: Mapped[str | None] = mapped_column(String(128))
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Dashboard users (for auth + audit log)."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
email: Mapped[str | None] = mapped_column(String(128))
|
||||
token_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
last_login: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
role: Mapped[str] = mapped_column(String(16), default="admin")
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Audit log for config changes."""
|
||||
|
||||
__tablename__ = "audit_log"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
username: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
action: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
target: Mapped[str | None] = mapped_column(String(256))
|
||||
old_value: Mapped[Any] = mapped_column(JSON)
|
||||
new_value: Mapped[Any] = mapped_column(JSON)
|
||||
|
||||
__table_args__ = (Index("ix_audit_timestamp", timestamp.desc()),)
|
||||
|
||||
|
||||
class CatalogEntry(Base):
|
||||
"""DB-backed catalog of AI models / extractors (caiet: Modul Dashboard).
|
||||
|
||||
Satisfies the requirement to administer "modele și extractoare conform datelor
|
||||
din baza de date" with full CRUD: capabilities, context length, CPU/GPU
|
||||
support, quantization, endpoint, limits and per-token cost. Seeded/refreshed
|
||||
from each service's ``/v1/info`` but editable independently.
|
||||
"""
|
||||
|
||||
__tablename__ = "catalog_entry"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False, default="model")
|
||||
name: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(128))
|
||||
# Owning service: llm / embeddings / rerank / audio / video / extractors / web
|
||||
service: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
|
||||
capabilities: Mapped[Any | None] = mapped_column(JSON)
|
||||
context_length: Mapped[int | None] = mapped_column(Integer)
|
||||
supports_cpu: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
supports_gpu: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
quantization: Mapped[str | None] = mapped_column(String(32))
|
||||
endpoint: Mapped[str | None] = mapped_column(String(256))
|
||||
limits: Mapped[Any | None] = mapped_column(JSON)
|
||||
cost_per_token: Mapped[float | None] = mapped_column(Numeric(16, 10))
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(128))
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_catalog_kind_service", kind, service),
|
||||
Index("uq_catalog_service_name", service, name, unique=True),
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Archive tables (for future claims-api module)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class ClaimsArchive(Base):
|
||||
"""Permanent storage for verified claims."""
|
||||
|
||||
__tablename__ = "claims_archive"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
claim: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
claim_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
source_request_id: Mapped[str | None] = mapped_column(String(64))
|
||||
|
||||
# Verdict
|
||||
verdict: Mapped[str | None] = mapped_column(String(32))
|
||||
confidence: Mapped[float | None] = mapped_column(Numeric(5, 4))
|
||||
summary: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
# Context
|
||||
primary_country: Mapped[str | None] = mapped_column(String(8))
|
||||
detected_language: Mapped[str | None] = mapped_column(String(8))
|
||||
entities: Mapped[dict[str, Any] | None] = mapped_column(JSON)
|
||||
|
||||
# Metadata
|
||||
promoted_by: Mapped[str | None] = mapped_column(String(128))
|
||||
tags: Mapped[list[str] | None] = mapped_column(JSON)
|
||||
|
||||
articles: Mapped[list["ClaimArticle"]] = relationship(
|
||||
back_populates="claim_ref", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class ArticlesArchive(Base):
|
||||
"""Permanent storage for article full-text."""
|
||||
|
||||
__tablename__ = "articles_archive"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
url: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
|
||||
url_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
canonical_url: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
title: Mapped[str | None] = mapped_column(Text)
|
||||
full_text: Mapped[str | None] = mapped_column(Text)
|
||||
publisher: Mapped[str | None] = mapped_column(String(256))
|
||||
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
retrieved_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now()
|
||||
)
|
||||
|
||||
extraction_method: Mapped[str | None] = mapped_column(String(16))
|
||||
credibility_score: Mapped[float | None] = mapped_column(Numeric(5, 4))
|
||||
|
||||
claims: Mapped[list["ClaimArticle"]] = relationship(back_populates="article_ref")
|
||||
|
||||
__table_args__ = (Index("ix_articles_url_hash", url_hash),)
|
||||
|
||||
|
||||
class ClaimArticle(Base):
|
||||
"""Many-to-many: claims to articles with relevance."""
|
||||
|
||||
__tablename__ = "claim_articles"
|
||||
|
||||
claim_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("claims_archive.id"), primary_key=True
|
||||
)
|
||||
article_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("articles_archive.id"), primary_key=True
|
||||
)
|
||||
relevance_score: Mapped[float | None] = mapped_column(Numeric(5, 4))
|
||||
snippet: Mapped[str | None] = mapped_column(Text)
|
||||
|
||||
claim_ref: Mapped["ClaimsArchive"] = relationship(back_populates="articles")
|
||||
article_ref: Mapped["ArticlesArchive"] = relationship(back_populates="claims")
|
||||
74
ai_platform/modules/dashboard/src/dashboard/db/session.py
Normal file
74
ai_platform/modules/dashboard/src/dashboard/db/session.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Database session management."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from dashboard.config import DashboardSettings
|
||||
from dashboard.db.models import Base
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("db.session")
|
||||
|
||||
_engine: AsyncEngine | None = None
|
||||
_session_factory: async_sessionmaker[AsyncSession] | None = None
|
||||
|
||||
|
||||
def init_engine(settings: DashboardSettings) -> AsyncEngine:
|
||||
"""Create the async engine and session factory."""
|
||||
global _engine, _session_factory
|
||||
|
||||
_engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
_session_factory = async_sessionmaker(
|
||||
_engine, class_=AsyncSession, expire_on_commit=False
|
||||
)
|
||||
logger.info("Database engine initialized")
|
||||
return _engine
|
||||
|
||||
|
||||
async def create_all_tables() -> None:
|
||||
"""Create all tables if they don't exist."""
|
||||
if _engine is None:
|
||||
raise RuntimeError("Engine not initialized")
|
||||
async with _engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
logger.info("Database tables ensured")
|
||||
|
||||
|
||||
async def close_engine() -> None:
|
||||
"""Close the database engine."""
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
await _engine.dispose()
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
|
||||
|
||||
async def get_session() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that yields a session."""
|
||||
if _session_factory is None:
|
||||
raise RuntimeError("Session factory not initialized")
|
||||
async with _session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
|
||||
|
||||
def get_session_factory() -> async_sessionmaker[AsyncSession]:
|
||||
"""Return the configured session factory."""
|
||||
if _session_factory is None:
|
||||
raise RuntimeError("Session factory not initialized")
|
||||
return _session_factory
|
||||
33
ai_platform/modules/dashboard/src/dashboard/logging.py
Normal file
33
ai_platform/modules/dashboard/src/dashboard/logging.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
"""Logging configuration."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
|
||||
def configure_logging(level: str = "INFO", json_output: bool = False) -> None:
|
||||
"""Configure root logger."""
|
||||
log_level = getattr(logging, level.upper(), logging.INFO)
|
||||
formatter: logging.Formatter
|
||||
if json_output:
|
||||
formatter = logging.Formatter(
|
||||
'{"time":"%(asctime)s","level":"%(levelname)s",'
|
||||
'"logger":"%(name)s","msg":"%(message)s"}'
|
||||
)
|
||||
else:
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
||||
)
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.handlers = [handler]
|
||||
root.setLevel(log_level)
|
||||
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
|
||||
|
||||
def get_logger(name: str) -> logging.Logger:
|
||||
"""Get named logger."""
|
||||
return logging.getLogger(f"dashboard.{name}")
|
||||
59
ai_platform/modules/dashboard/src/dashboard/pricing.py
Normal file
59
ai_platform/modules/dashboard/src/dashboard/pricing.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""Per-provider pricing heuristics for cost estimation.
|
||||
|
||||
These rates are approximations used to compute an estimated cost per
|
||||
request at ingest time. Real billing lives on the provider side — the
|
||||
dashboard just needs a "good enough" number for internal reporting.
|
||||
|
||||
Pricing cheat-sheet (as of 2026-04):
|
||||
SerpAPI Starter: $25/month / 1000 searches = $0.025 per search
|
||||
Tavily: free tier = $0
|
||||
Brave: free (Data for AI tier) = $0
|
||||
LinkUp: pay-as-you-go, unknown = $0 (placeholder)
|
||||
SearXNG: self-hosted = $0
|
||||
OpenRouter (gemini-3.1-flash-lite-preview):
|
||||
$0.25/M input, $1.50/M output
|
||||
— a typical gather uses ~4k input + ~800 output tokens
|
||||
≈ $0.0022 per gather
|
||||
|
||||
Keep this module free of IO so it can be imported in routes and tests.
|
||||
"""
|
||||
|
||||
# Per-request cost in USD for search providers
|
||||
SEARCH_COST_USD: dict[str, float] = {
|
||||
"serpapi": 0.025,
|
||||
"tavily": 0.0,
|
||||
"brave": 0.0,
|
||||
"linkup": 0.0,
|
||||
"exa": 0.01, # rough — pay-as-you-go
|
||||
"searxng": 0.0,
|
||||
"paid-rotation": 0.025, # worst-case assume SerpAPI was rotated
|
||||
}
|
||||
|
||||
# Per-request cost in USD for full gather pipelines (includes context + evidence LLM)
|
||||
GATHER_LLM_COST_USD: dict[str, float] = {
|
||||
"free": 0.0, # runs on local Qwen
|
||||
"premium": 0.003, # ~3 OpenRouter calls (context + evidence + summary)
|
||||
}
|
||||
|
||||
|
||||
def estimate_search_cost(provider: str | None) -> float:
|
||||
"""Return estimated cost for a single /v1/search call."""
|
||||
if provider is None:
|
||||
return 0.0
|
||||
return SEARCH_COST_USD.get(provider.lower(), 0.0)
|
||||
|
||||
|
||||
def estimate_gather_cost(tier: str | None, provider: str | None) -> float:
|
||||
"""Return estimated cost for a full /v1/gather pipeline."""
|
||||
search = estimate_search_cost(provider)
|
||||
llm = GATHER_LLM_COST_USD.get((tier or "free").lower(), 0.0)
|
||||
return round(search + llm, 6)
|
||||
|
||||
|
||||
def estimate_cost(endpoint: str, tier: str | None, provider: str | None) -> float:
|
||||
"""Dispatch by endpoint."""
|
||||
if endpoint == "/v1/gather":
|
||||
return estimate_gather_cost(tier, provider)
|
||||
if endpoint == "/v1/search":
|
||||
return estimate_search_cost(provider)
|
||||
return 0.0
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Live provider stats fetchers."""
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
"""Base types for provider stats."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProviderStats:
|
||||
"""Live stats for a single provider."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
kind: str # "search" | "llm" | "internal"
|
||||
healthy: bool
|
||||
message: str | None = None
|
||||
|
||||
# Quota / billing
|
||||
plan_name: str | None = None
|
||||
plan_price_monthly: float | None = None
|
||||
quota_limit: float | None = None
|
||||
quota_used: float | None = None
|
||||
quota_unit: str = "requests" # "requests" | "usd" | "credits"
|
||||
quota_period: str = "month"
|
||||
|
||||
# Rate limits
|
||||
rate_limit: str | None = None
|
||||
|
||||
# Last error
|
||||
last_error: str | None = None
|
||||
|
||||
# Extra provider-specific fields
|
||||
extra: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def quota_remaining(self) -> float | None:
|
||||
if self.quota_limit is None or self.quota_used is None:
|
||||
return None
|
||||
return max(0.0, self.quota_limit - self.quota_used)
|
||||
|
||||
@property
|
||||
def quota_percent_used(self) -> float | None:
|
||||
if self.quota_limit is None or self.quota_used is None or self.quota_limit == 0:
|
||||
return None
|
||||
return round((self.quota_used / self.quota_limit) * 100, 1)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"name": self.name,
|
||||
"display_name": self.display_name,
|
||||
"kind": self.kind,
|
||||
"healthy": self.healthy,
|
||||
"message": self.message,
|
||||
"plan_name": self.plan_name,
|
||||
"plan_price_monthly": self.plan_price_monthly,
|
||||
"quota_limit": self.quota_limit,
|
||||
"quota_used": self.quota_used,
|
||||
"quota_remaining": self.quota_remaining,
|
||||
"quota_percent_used": self.quota_percent_used,
|
||||
"quota_unit": self.quota_unit,
|
||||
"quota_period": self.quota_period,
|
||||
"rate_limit": self.rate_limit,
|
||||
"last_error": self.last_error,
|
||||
"extra": self.extra,
|
||||
"checked_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
"""Brave Search live stats fetcher."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def fetch_brave_stats(api_key: str | None) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="brave",
|
||||
display_name="Brave Search",
|
||||
kind="search",
|
||||
healthy=False,
|
||||
)
|
||||
if not api_key:
|
||||
stats.message = "API key not configured"
|
||||
return stats
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
# Brave has no dedicated account endpoint — probe with rate limit headers
|
||||
resp = await client.get(
|
||||
"https://api.search.brave.com/res/v1/web/search",
|
||||
params={"q": "test", "count": 1},
|
||||
headers={"X-Subscription-Token": api_key},
|
||||
)
|
||||
|
||||
stats.healthy = resp.status_code == 200
|
||||
headers = resp.headers
|
||||
|
||||
# Parse rate limit headers (format: "second, month")
|
||||
rl_limit = headers.get("x-ratelimit-limit", "")
|
||||
rl_remaining = headers.get("x-ratelimit-remaining", "")
|
||||
|
||||
if rl_limit:
|
||||
parts = [p.strip() for p in rl_limit.split(",")]
|
||||
if len(parts) >= 1:
|
||||
stats.rate_limit = f"{parts[0]}/second"
|
||||
|
||||
if rl_remaining and rl_limit:
|
||||
rem_parts = [p.strip() for p in rl_remaining.split(",")]
|
||||
lim_parts = [p.strip() for p in rl_limit.split(",")]
|
||||
if len(rem_parts) >= 2 and len(lim_parts) >= 2:
|
||||
try:
|
||||
monthly_limit = float(lim_parts[1])
|
||||
monthly_remaining = float(rem_parts[1])
|
||||
if monthly_limit > 0:
|
||||
stats.quota_limit = monthly_limit
|
||||
stats.quota_used = monthly_limit - monthly_remaining
|
||||
stats.quota_unit = "requests"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
stats.plan_name = "Free / Data for AI"
|
||||
stats.extra = {
|
||||
"rate_limit_raw": rl_limit,
|
||||
"remaining_raw": rl_remaining,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
stats.last_error = str(e)
|
||||
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||
|
||||
return stats
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"""Internal service health checks (SearXNG, vLLM, llama.cpp, web-api)."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def _probe(url: str, timeout: float = 5.0) -> tuple[bool, str | None]:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
resp = await client.get(url)
|
||||
return resp.status_code == 200, None
|
||||
except Exception as e:
|
||||
return False, f"{type(e).__name__}: {e}"
|
||||
|
||||
|
||||
async def fetch_searxng_stats(base_url: str) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="searxng",
|
||||
display_name="SearXNG",
|
||||
kind="search",
|
||||
healthy=False,
|
||||
plan_name="Self-hosted",
|
||||
quota_limit=None,
|
||||
quota_used=None,
|
||||
)
|
||||
healthy, err = await _probe(f"{base_url}/healthz")
|
||||
stats.healthy = healthy
|
||||
if not healthy:
|
||||
stats.last_error = err
|
||||
stats.message = "Unreachable"
|
||||
return stats
|
||||
|
||||
|
||||
async def fetch_web_api_stats(base_url: str) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="web_api",
|
||||
display_name="Web API",
|
||||
kind="internal",
|
||||
healthy=False,
|
||||
)
|
||||
healthy, err = await _probe(f"{base_url}/health")
|
||||
stats.healthy = healthy
|
||||
if not healthy:
|
||||
stats.last_error = err
|
||||
stats.message = "Unreachable"
|
||||
return stats
|
||||
|
||||
|
||||
async def fetch_vllm_qwen_stats(base_url: str) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="vllm_qwen",
|
||||
display_name="Qwen3.5-35B-A3B (local vLLM)",
|
||||
kind="llm",
|
||||
healthy=False,
|
||||
plan_name="Local GPU",
|
||||
)
|
||||
healthy, err = await _probe(f"{base_url}/v1/models")
|
||||
stats.healthy = healthy
|
||||
if not healthy:
|
||||
stats.last_error = err
|
||||
stats.message = "Unreachable"
|
||||
return stats
|
||||
|
||||
|
||||
async def fetch_llamacpp_stats(urls: list[str]) -> list[ProviderStats]:
|
||||
results = []
|
||||
for i, url in enumerate(urls, 1):
|
||||
stats = ProviderStats(
|
||||
name=f"llamacpp_{i}",
|
||||
display_name=f"llama.cpp #{i} ({url})",
|
||||
kind="llm",
|
||||
healthy=False,
|
||||
plan_name="External GPU",
|
||||
)
|
||||
healthy, err = await _probe(f"{url}/v1/models")
|
||||
stats.healthy = healthy
|
||||
if healthy:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(f"{url}/v1/models")
|
||||
data = resp.json()
|
||||
models = data.get("models") or data.get("data") or []
|
||||
if models:
|
||||
first = models[0]
|
||||
stats.extra = {"model": str(first.get("name") or first.get("id", ""))}
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
stats.last_error = err
|
||||
stats.message = "Unreachable"
|
||||
results.append(stats)
|
||||
return results
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
"""LinkUp live stats (limited — no billing endpoint)."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def fetch_linkup_stats(api_key: str | None) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="linkup",
|
||||
display_name="LinkUp",
|
||||
kind="search",
|
||||
healthy=False,
|
||||
)
|
||||
if not api_key:
|
||||
stats.message = "API key not configured"
|
||||
return stats
|
||||
|
||||
# LinkUp has no public billing endpoint; we only probe health
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.post(
|
||||
"https://api.linkup.so/v1/search",
|
||||
json={"q": "test", "depth": "standard", "outputType": "searchResults"},
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
stats.healthy = resp.status_code == 200
|
||||
stats.message = None if stats.healthy else f"HTTP {resp.status_code}"
|
||||
stats.plan_name = "Standard"
|
||||
except Exception as e:
|
||||
stats.last_error = str(e)
|
||||
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||
|
||||
return stats
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
"""OpenRouter live credits fetcher."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def fetch_openrouter_stats(api_key: str | None) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="openrouter",
|
||||
display_name="OpenRouter",
|
||||
kind="llm",
|
||||
healthy=False,
|
||||
)
|
||||
if not api_key:
|
||||
stats.message = "API key not configured"
|
||||
return stats
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(
|
||||
"https://openrouter.ai/api/v1/credits",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data", {})
|
||||
|
||||
stats.healthy = True
|
||||
stats.quota_limit = float(data.get("total_credits") or 0)
|
||||
stats.quota_used = float(data.get("total_usage") or 0)
|
||||
stats.quota_unit = "usd"
|
||||
stats.quota_period = "total"
|
||||
stats.plan_name = "Pay-as-you-go"
|
||||
|
||||
except Exception as e:
|
||||
stats.last_error = str(e)
|
||||
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
async def fetch_openrouter_models(api_key: str | None) -> list[dict]:
|
||||
"""Fetch available models from OpenRouter for config dropdown."""
|
||||
if not api_key:
|
||||
return []
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(
|
||||
"https://openrouter.ai/api/v1/models",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data", [])
|
||||
|
||||
models: list[dict] = []
|
||||
for m in data:
|
||||
pricing = m.get("pricing", {})
|
||||
try:
|
||||
prompt_per_m = float(pricing.get("prompt", "0")) * 1_000_000
|
||||
completion_per_m = float(pricing.get("completion", "0")) * 1_000_000
|
||||
except (ValueError, TypeError):
|
||||
prompt_per_m = 0.0
|
||||
completion_per_m = 0.0
|
||||
models.append(
|
||||
{
|
||||
"id": m.get("id"),
|
||||
"name": m.get("name", m.get("id")),
|
||||
"context_length": m.get("context_length", 0),
|
||||
"prompt_cost_per_m": round(prompt_per_m, 2),
|
||||
"completion_cost_per_m": round(completion_per_m, 2),
|
||||
}
|
||||
)
|
||||
return models
|
||||
except Exception:
|
||||
return []
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
"""Provider registry — aggregates all live stats with caching."""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from dashboard.config import DashboardSettings
|
||||
from dashboard.logging import get_logger
|
||||
from dashboard.providers.base import ProviderStats
|
||||
from dashboard.providers.brave import fetch_brave_stats
|
||||
from dashboard.providers.internal import (
|
||||
fetch_llamacpp_stats,
|
||||
fetch_searxng_stats,
|
||||
fetch_vllm_qwen_stats,
|
||||
fetch_web_api_stats,
|
||||
)
|
||||
from dashboard.providers.linkup import fetch_linkup_stats
|
||||
from dashboard.providers.openrouter import fetch_openrouter_stats
|
||||
from dashboard.providers.serpapi import fetch_serpapi_stats
|
||||
from dashboard.providers.tavily import fetch_tavily_stats
|
||||
|
||||
logger = get_logger("providers.registry")
|
||||
|
||||
|
||||
class ProviderRegistry:
|
||||
"""Caches provider stats with configurable TTL."""
|
||||
|
||||
def __init__(self, settings: DashboardSettings) -> None:
|
||||
self.settings = settings
|
||||
self._cache: dict[str, ProviderStats] = {}
|
||||
self._cache_time: float = 0.0
|
||||
self._cache_wall: datetime | None = None
|
||||
self._lock = asyncio.Lock()
|
||||
|
||||
@property
|
||||
def last_refresh_iso(self) -> str | None:
|
||||
return self._cache_wall.isoformat() if self._cache_wall else None
|
||||
|
||||
@property
|
||||
def age_seconds(self) -> float | None:
|
||||
if not self._cache_wall:
|
||||
return None
|
||||
return (datetime.now(timezone.utc) - self._cache_wall).total_seconds()
|
||||
|
||||
async def get_all(self, force: bool = False) -> list[ProviderStats]:
|
||||
"""Fetch all provider stats (cached)."""
|
||||
async with self._lock:
|
||||
now = time.monotonic()
|
||||
age = now - self._cache_time
|
||||
if (
|
||||
not force
|
||||
and self._cache
|
||||
and age < self.settings.provider_stats_cache_seconds
|
||||
):
|
||||
return list(self._cache.values())
|
||||
|
||||
results = await self._fetch_all()
|
||||
self._cache = {s.name: s for s in results}
|
||||
self._cache_time = now
|
||||
self._cache_wall = datetime.now(timezone.utc)
|
||||
return results
|
||||
|
||||
async def _fetch_all(self) -> list[ProviderStats]:
|
||||
tasks = [
|
||||
fetch_serpapi_stats(self.settings.serpapi_api_key),
|
||||
fetch_tavily_stats(self.settings.tavily_api_key),
|
||||
fetch_brave_stats(self.settings.brave_api_key),
|
||||
fetch_linkup_stats(self.settings.linkup_api_key),
|
||||
fetch_openrouter_stats(self.settings.openrouter_api_key),
|
||||
fetch_searxng_stats(self.settings.searxng_url),
|
||||
fetch_web_api_stats(self.settings.web_api_url),
|
||||
fetch_vllm_qwen_stats(self.settings.vllm_qwen_url),
|
||||
]
|
||||
|
||||
gathered = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
stats: list[ProviderStats] = []
|
||||
for i, r in enumerate(gathered):
|
||||
if isinstance(r, BaseException):
|
||||
logger.warning("Provider fetch #%d failed: %s", i, r)
|
||||
continue
|
||||
stats.append(r) # type: ignore[arg-type]
|
||||
|
||||
# llama.cpp servers (variable count)
|
||||
try:
|
||||
llamacpp = await fetch_llamacpp_stats(self.settings.llamacpp_urls)
|
||||
stats.extend(llamacpp)
|
||||
except Exception as e:
|
||||
logger.warning("llama.cpp fetch failed: %s", e)
|
||||
|
||||
return stats
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"""SerpAPI live quota fetcher."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def fetch_serpapi_stats(api_key: str | None) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="serpapi",
|
||||
display_name="SerpAPI",
|
||||
kind="search",
|
||||
healthy=False,
|
||||
)
|
||||
if not api_key:
|
||||
stats.message = "API key not configured"
|
||||
return stats
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(
|
||||
"https://serpapi.com/account",
|
||||
params={"api_key": api_key},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
stats.healthy = data.get("account_status", "").lower() == "active"
|
||||
stats.plan_name = data.get("plan_name")
|
||||
stats.plan_price_monthly = float(data.get("plan_monthly_price") or 0)
|
||||
stats.quota_limit = float(data.get("searches_per_month") or 0)
|
||||
stats.quota_used = float(data.get("this_month_usage") or 0)
|
||||
stats.quota_unit = "searches"
|
||||
stats.rate_limit = f"{data.get('account_rate_limit_per_hour', 0)}/hour"
|
||||
stats.extra = {
|
||||
"email": str(data.get("account_email", "")),
|
||||
"extra_credits": str(data.get("extra_credits", 0)),
|
||||
"this_hour": str(data.get("this_hour_searches", 0)),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
stats.last_error = str(e)
|
||||
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||
|
||||
return stats
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"""Tavily live usage fetcher."""
|
||||
|
||||
import httpx
|
||||
|
||||
from dashboard.providers.base import ProviderStats
|
||||
|
||||
|
||||
async def fetch_tavily_stats(api_key: str | None) -> ProviderStats:
|
||||
stats = ProviderStats(
|
||||
name="tavily",
|
||||
display_name="Tavily",
|
||||
kind="search",
|
||||
healthy=False,
|
||||
)
|
||||
if not api_key:
|
||||
stats.message = "API key not configured"
|
||||
return stats
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
resp = await client.get(
|
||||
"https://api.tavily.com/usage",
|
||||
headers={"Authorization": f"Bearer {api_key}"},
|
||||
)
|
||||
|
||||
# Tavily fronts api.tavily.com with AWS WAF; some IPs / regions get a
|
||||
# 202 challenge with an empty body (header x-amzn-waf-action=challenge).
|
||||
# That's not a JSON error — the API just refused us. Surface it clearly
|
||||
# instead of pretending the response was malformed JSON.
|
||||
if resp.headers.get("x-amzn-waf-action"):
|
||||
stats.message = (
|
||||
f"Tavily WAF challenge ({resp.headers['x-amzn-waf-action']}) — "
|
||||
"live quota probe blocked from this IP"
|
||||
)
|
||||
stats.last_error = stats.message
|
||||
return stats
|
||||
|
||||
# Empty body or non-JSON content-type → can't parse, but quota itself
|
||||
# may still be fine. Treat as "tracking unavailable" not a hard error.
|
||||
if not resp.content:
|
||||
stats.message = f"Empty body from Tavily ({resp.status_code})"
|
||||
stats.last_error = stats.message
|
||||
return stats
|
||||
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
key_data = data.get("key", {})
|
||||
account_data = data.get("account", {})
|
||||
|
||||
stats.healthy = True
|
||||
stats.plan_name = account_data.get("current_plan")
|
||||
stats.quota_limit = float(account_data.get("plan_limit") or 0)
|
||||
stats.quota_used = float(account_data.get("plan_usage") or 0)
|
||||
stats.quota_unit = "requests"
|
||||
stats.extra = {
|
||||
"search": str(account_data.get("search_usage", 0)),
|
||||
"crawl": str(account_data.get("crawl_usage", 0)),
|
||||
"extract": str(account_data.get("extract_usage", 0)),
|
||||
"key_usage": str(key_data.get("usage", 0)),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
stats.last_error = str(e)
|
||||
stats.message = f"Failed to fetch: {type(e).__name__}"
|
||||
|
||||
return stats
|
||||
41
ai_platform/modules/dashboard/src/dashboard/retention.py
Normal file
41
ai_platform/modules/dashboard/src/dashboard/retention.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
"""Background task for data retention."""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import delete
|
||||
|
||||
from dashboard.config import DashboardSettings
|
||||
from dashboard.db.models import RequestHistory
|
||||
from dashboard.db.session import get_session_factory
|
||||
from dashboard.logging import get_logger
|
||||
|
||||
logger = get_logger("retention")
|
||||
|
||||
|
||||
async def purge_old_history(days: int) -> int:
|
||||
"""Delete history rows older than N days."""
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
||||
factory = get_session_factory()
|
||||
async with factory() as session:
|
||||
result = await session.execute(
|
||||
delete(RequestHistory).where(RequestHistory.created_at < cutoff)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
|
||||
|
||||
async def retention_loop(settings: DashboardSettings) -> None:
|
||||
"""Run purge once per hour."""
|
||||
while True:
|
||||
try:
|
||||
deleted = await purge_old_history(settings.history_retention_days)
|
||||
if deleted:
|
||||
logger.info(
|
||||
"Purged %d rows older than %d days",
|
||||
deleted,
|
||||
settings.history_retention_days,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Retention job failed: %s", e)
|
||||
await asyncio.sleep(3600)
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Archive — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Claims Archive</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
Permanently stored claims + articles. Consumed by the future claims-api
|
||||
to serve gather responses without network search.
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-xs">
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-white">{{ total_claims }}</p>
|
||||
<p class="text-slate-400">claims</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-2xl font-bold text-white">{{ total_articles }}</p>
|
||||
<p class="text-slate-400">articles</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="get" class="mb-6">
|
||||
<input type="text" name="q" value="{{ query }}" placeholder="Search claims..."
|
||||
class="w-full bg-slate-900 text-white text-sm rounded-md border border-slate-800 px-4 py-2.5 focus:outline-none focus:border-indigo-600">
|
||||
</form>
|
||||
|
||||
<div class="space-y-3">
|
||||
{% for claim in claims %}
|
||||
<a href="/archive/{{ claim.id }}" class="block bg-slate-900 rounded-xl border border-slate-800 p-4 hover:border-slate-700 transition">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-white font-medium">{{ claim.claim }}</p>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs text-slate-400">
|
||||
{% if claim.primary_country %}<span>🌍 {{ claim.primary_country }}</span>{% endif %}
|
||||
{% if claim.detected_language %}<span>💬 {{ claim.detected_language }}</span>{% endif %}
|
||||
{% if claim.promoted_by %}<span>👤 {{ claim.promoted_by }}</span>{% endif %}
|
||||
{% if claim.created_at %}<span>🕐 {{ claim.created_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if claim.verdict %}
|
||||
<span class="inline-flex px-2 py-0.5 text-xs rounded-full bg-emerald-900/50 text-emerald-300 border border-emerald-800 shrink-0">
|
||||
{{ claim.verdict }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-12 text-center text-slate-500">
|
||||
No claims archived yet. Promote a gather from the <a href="/history" class="text-indigo-400 hover:text-indigo-300">History</a> page.
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Claim #{{ claim.id }} — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<a href="/archive" class="text-sm text-slate-400 hover:text-white">← Back to archive</a>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-xl font-bold text-white">{{ claim.claim }}</h1>
|
||||
<div class="flex items-center gap-3 mt-2 text-xs text-slate-400">
|
||||
{% if claim.primary_country %}<span>🌍 {{ claim.primary_country }}</span>{% endif %}
|
||||
{% if claim.detected_language %}<span>💬 {{ claim.detected_language }}</span>{% endif %}
|
||||
{% if claim.promoted_by %}<span>👤 {{ claim.promoted_by }}</span>{% endif %}
|
||||
{% if claim.created_at %}<span>🕐 {{ claim.created_at.strftime('%Y-%m-%d %H:%M') }}</span>{% endif %}
|
||||
{% if claim.source_request_id %}
|
||||
<a href="/history/{{ claim.source_request_id }}" class="text-indigo-400 hover:text-indigo-300">source request →</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if claim.summary %}
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Summary</h2>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-slate-200">{{ claim.summary }}</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if claim.entities %}
|
||||
<section class="mb-8">
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Entities</h2>
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4 text-xs text-slate-300 grid grid-cols-3 gap-4">
|
||||
{% for key in ['persons', 'institutions', 'locations'] %}
|
||||
<div>
|
||||
<p class="uppercase text-slate-500 font-semibold mb-1">{{ key }}</p>
|
||||
<ul class="space-y-1">
|
||||
{% for item in (claim.entities.get(key) or []) %}<li>{{ item }}</li>{% else %}<li class="text-slate-600">—</li>{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-slate-400 uppercase mb-2">Articles ({{ articles|length }})</h2>
|
||||
<div class="space-y-3">
|
||||
{% for link in articles %}
|
||||
{% set a = link.article %}
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 p-4">
|
||||
<div class="flex items-start justify-between gap-4 mb-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="{{ a.url }}" target="_blank" rel="noopener" class="text-white font-medium hover:text-indigo-300">{{ a.title or a.url }}</a>
|
||||
<p class="text-xs text-slate-500 mt-0.5 truncate">{{ a.publisher or a.url }}</p>
|
||||
</div>
|
||||
{% if link.link.relevance_score %}
|
||||
<span class="shrink-0 text-xs font-mono text-slate-400">rel: {{ '%.2f'|format(link.link.relevance_score|float) }}</span>
|
||||
{% endif %}
|
||||
{% if a.credibility_score %}
|
||||
<span class="shrink-0 text-xs font-mono text-slate-400">cred: {{ '%.2f'|format(a.credibility_score|float) }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if link.link.snippet %}
|
||||
<p class="text-xs text-slate-300 italic">"{{ link.link.snippet[:300] }}{% if link.link.snippet|length > 300 %}…{% endif %}"</p>
|
||||
{% endif %}
|
||||
{% if a.full_text %}
|
||||
<details class="mt-2 text-xs text-slate-400">
|
||||
<summary class="cursor-pointer hover:text-white">View full text ({{ a.full_text|length }} chars)</summary>
|
||||
<pre class="mt-2 p-3 bg-slate-800/50 rounded whitespace-pre-wrap text-[11px]">{{ a.full_text[:3000] }}{% if a.full_text|length > 3000 %}…{% endif %}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Audit Log — didiAI Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-white">Audit Log</h1>
|
||||
<p class="mt-1 text-sm text-slate-400">Who changed what, when. Covers config changes and auth events.</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-900 rounded-xl border border-slate-800 overflow-hidden">
|
||||
<div class="overflow-x-auto scrollbar-thin">
|
||||
<table class="min-w-full divide-y divide-slate-800">
|
||||
<thead class="bg-slate-800/50">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Time</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">User</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Action</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">Target</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">From</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-semibold text-slate-300 uppercase">To</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-800 text-sm">
|
||||
{% for entry in entries %}
|
||||
<tr class="hover:bg-slate-800/30">
|
||||
<td class="px-4 py-3 text-slate-400 font-mono text-xs whitespace-nowrap">
|
||||
{{ entry.timestamp.strftime('%m-%d %H:%M:%S') if entry.timestamp else '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-white font-medium">{{ entry.username }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% set action_color = {
|
||||
'config.set': 'bg-indigo-900/50 text-indigo-300 border-indigo-800',
|
||||
'config.reset': 'bg-amber-900/50 text-amber-300 border-amber-800',
|
||||
'config.delete': 'bg-rose-900/50 text-rose-300 border-rose-800',
|
||||
} %}
|
||||
<span class="inline-flex px-2 py-0.5 text-xs rounded-full border {{ action_color.get(entry.action, 'bg-slate-800 text-slate-300 border-slate-700') }}">
|
||||
{{ entry.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-300">{{ entry.target or '—' }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-500">{{ entry.old_value|tojson if entry.old_value is not none else '—' }}</td>
|
||||
<td class="px-4 py-3 font-mono text-xs text-slate-300">{{ entry.new_value|tojson if entry.new_value is not none else '—' }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="6" class="px-4 py-12 text-center text-slate-500">No audit entries yet.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full bg-slate-950">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}didiAI Dashboard{% endblock %}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script defer src="https://unpkg.com/alpinejs@3.14.3/dist/cdn.min.js"></script>
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
|
||||
.scrollbar-thin::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
.scrollbar-thin::-webkit-scrollbar-track { background: #1e293b; }
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb { background: #475569; border-radius: 3px; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="h-full text-slate-200">
|
||||
<div class="min-h-full">
|
||||
<nav class="bg-slate-900 border-b border-slate-800">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0 flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center font-bold">d</div>
|
||||
<span class="text-white font-semibold tracking-tight">didiAI Dashboard</span>
|
||||
</div>
|
||||
<div class="ml-10 flex items-baseline space-x-2">
|
||||
{% set nav_items = [
|
||||
('overview', '/', 'Overview'),
|
||||
('providers', '/providers', 'Providers'),
|
||||
('history', '/history', 'History'),
|
||||
('archive', '/archive', 'Archive'),
|
||||
('cost', '/cost', 'Cost'),
|
||||
('config', '/config', 'Config'),
|
||||
('audit', '/audit', 'Audit'),
|
||||
] %}
|
||||
{% for key, href, label in nav_items %}
|
||||
<a href="{{ href }}" class="rounded-md px-3 py-2 text-sm font-medium transition
|
||||
{% if active_page == key %}bg-slate-800 text-white{% else %}text-slate-300 hover:bg-slate-800 hover:text-white{% endif %}">
|
||||
{{ label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-xs text-slate-400">
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="py-8">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -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 %}
|
||||
153
ai_platform/modules/dashboard/src/dashboard/templates/cost.html
Normal file
153
ai_platform/modules/dashboard/src/dashboard/templates/cost.html
Normal 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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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 %}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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 %}
|
||||
0
ai_platform/modules/dashboard/tests/__init__.py
Normal file
0
ai_platform/modules/dashboard/tests/__init__.py
Normal file
96
ai_platform/modules/dashboard/tests/test_catalog.py
Normal file
96
ai_platform/modules/dashboard/tests/test_catalog.py
Normal 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
Loading…
Add table
Add a link
Reference in a new issue