# Dev Contract: Preflight-first, Node-specific, Zero Assumptions (v0.1) ## 0. Заборона припущень Будь-яка дія/пропозиція щодо моделей, провайдерів, портів, compose або routing **заборонена без preflight snapshot** з цільової ноди. Без snapshot — ні коміт, ні деплой, ні рекомендація. ## 1. Обов'язковий Preflight Snapshot ### 1.1 Збір Перед кожною зміною запустити `ops/fabric_snapshot.py` на цільовій ноді: ```bash # NODA2 (локально) python3 ops/fabric_snapshot.py --node-id NODA2 # NODA1 (через SSH tunnel) ssh -L 18099:127.0.0.1:8099 -L 18109:127.0.0.1:8109 \ -L 19102:127.0.0.1:9102 -fN root@144.76.224.179 python3 ops/fabric_snapshot.py --node-id noda1 \ --ncs http://127.0.0.1:18099 --worker http://127.0.0.1:18109 \ --router http://127.0.0.1:19102 --ollama http://127.0.0.1:21434 \ --ssh root@144.76.224.179 ``` Snapshot зберігається в `ops/preflight_snapshots/_.json`. ### 1.2 Endpoints (всі обов'язкові) | Endpoint | Що перевіряє | |---|---| | `NCS /capabilities` | served_models, capabilities, node_load, runtimes | | `NCS /capabilities/caps` | capability flags (stt/tts/ocr/image) | | `NCS /capabilities/installed` | installed_artifacts (disk scan) | | `node-worker /caps` | provider flags (STT_PROVIDER, TTS_PROVIDER...) | | `node-worker /healthz` | NATS connection, concurrency | | `Router /v1/capabilities` | global view (всі ноди, capabilities_by_node) | | `Router /v1/models` | глобальний пул моделей | | `Ollama /api/tags` | реальні Ollama моделі на ноді | | `docker ps` | список контейнерів | ### 1.3 Quality gate (must-pass) Snapshot валідний тільки якщо: - Router healthy - NCS healthy - Node-worker healthy - `capabilities_by_node` містить цільову ноду - `served_models` не порожній (або є пояснення "compute-less node") ## 2. Served ≠ Installed ### 2.1 Два шари правди | Шар | Джерело | Використання | |---|---|---| | **served_models** | NCS /capabilities → runtimes (Ollama/llama-server/...) | Routing, model selection, offload | | **installed_artifacts** | NCS /capabilities/installed → disk scan | Інвентаризація, recommendations, cleanup | Модель на диску — це **candidate**, не "доступна". ### 2.2 Заборона hardcode - Заборонено комітити `models:` списки в swapper/router configs - Дозволено: policy-only `prefer` (тип/клас моделі), але не імена, крім критичних cloud SKU ## 3. Capability-first routing ### 3.1 Routing rules Router обирає **ноду**, не "модель": 1. `find_nodes_with_capability(cap)` — тільки ноди де cap=true 2. `require_fresh_caps(ttl=30)` — preflight guard 3. Circuit breaker — виключити ноди з відкритим breaker 4. Load scoring — `inflight * 10 + (100 if mem_pressure=high)` 5. Offload через NATS `node.{id}.{cap}.request` ### 3.2 Fail fast Якщо capability відсутня на всіх нодах — **fail fast** з явним повідомленням: ```json {"error": "No node with capability 'stt' available", "capabilities_by_node": {...}} ``` Заборонено: тихий fallback на cloud без WARNING log. ### 3.3 Нодозалежність STT/TTS/OCR/Image **можуть бути різними** на різних нодах: - NODA2: `STT_PROVIDER=mlx_whisper`, `TTS_PROVIDER=mlx_kokoro` - NODA1: `STT_PROVIDER=none`, `OCR_PROVIDER=vision_prompted` Вмикання capability = тільки через env flags в node-worker → `/caps`. ## 4. Безпечний контроль змін ### 4.1 План змін (обов'язково) Перед зміною відповісти на: 1. **Що** міняємо 2. **Чому** 3. **Що може зламатися** 4. **Як перевіряємо** (postflight) 5. **Rollback** (точна команда) ### 4.2 Rollback Кожна зміна має мати: - git commit hash / tag - одну команду rollback (`docker compose up -d --force-recreate `, `git checkout`) ## 5. Postflight Після кожної зміни — повторний snapshot і порівняння: - served_models count (не зменшився без причини) - capabilities map (нові = очікувані) - container count - error rate (prom_metrics) ## 6. Жодних прихованих fallback - Невідомий профіль або відсутній API key → WARNING + deterministic fallback на `agent.fallback_llm` - Заборонено: "мовчки пішли в DeepSeek" без логу ## 7. Канонічні артефакти | Артефакт | Призначення | |---|---| | `ops/fabric_snapshot.py` | Збір повного snapshot | | `ops/fabric_preflight.sh` | Quick check + snapshot save | | `ops/preflight_snapshots/` | Зберігання snapshots | | `docs/fabric_contract.md` | Цей контракт | ## Реальний стан (snapshot 2026-02-27) ### NODA1 (production) - **49 контейнерів** (gateway, router, memory, qdrant, neo4j, redis, postgres, minio, rag, swapper, farmos, brand-*, oneok-*, plant-vision, crawl4ai, grafana, prometheus...) - **5 Ollama моделей**: qwen3-vl:8b (vision), qwen3:8b, qwen3.5:27b-q4_K_M, smollm2:135m, deepseek-v3.1:671b-cloud - **14 Telegram агентів** active - **NCS P3.5 не задеплоєний** — capabilities flags відсутні, installed_artifacts = 0 - `swapper=disabled`, worker NATS connected ### NODA2 (development) - **14 контейнерів** (router, node-worker, node-capabilities, nats, gateway, memory, qdrant, postgres, neo4j, redis, open-webui, sofiia-console, swapper) - **13 served моделей** (Ollama: 12 + llama_server: 1) - **29 installed artifacts** на диску (150.3GB LLM + 0.3GB TTS kokoro-v1_0) - **capabilities**: llm=Y, vision=Y, ocr=Y, stt=Y, tts=Y, image=N ← Phase 1 enabled - `STT_PROVIDER=memory_service`, `TTS_PROVIDER=memory_service`, `OCR_PROVIDER=vision_prompted` --- ## Phase 1: STT/TTS via Memory Service delegation (2026-02-27) ### Мотивація Увімкнення `stt=true` / `tts=true` в Fabric без нових мікросервісів і без ризику MLX-залежностей. ### Архітектура ``` Fabric Router → find_nodes_with_capability("stt"/"tts") → NODA2 node-worker → STT_PROVIDER=memory_service → stt_memory_service.transcribe() → POST http://memory-service:8000/voice/stt (faster-whisper) → {text, segments, language, meta} Fabric Router → NODA2 node-worker → TTS_PROVIDER=memory_service → tts_memory_service.synthesize() → POST http://memory-service:8000/voice/tts (edge-tts: Polina/Ostap Neural uk-UA) → {audio_b64, format="mp3", meta} ``` ### Контракти **STT вхід:** ```json { "audio_b64": "", // OR "audio_url": "http://...", // one is required "language": "uk", // optional "filename": "audio.wav" // optional } ``` **STT вихід (fabric contract):** ```json {"text": "...", "segments": [], "language": "uk", "meta": {...}, "provider": "memory_service"} ``` **TTS вхід:** ```json {"text": "...", "voice": "Polina", "speed": 1.0} ``` **TTS вихід (fabric contract):** ```json {"audio_b64": "", "format": "mp3", "meta": {...}, "provider": "memory_service"} ``` ### Обмеження Phase 1 - **ffmpeg=false**: лише формати що Memory Service ковтає нативно (WAV рекомендований) - **Текст TTS**: max 500 символів (Memory Service limit) - **Голоси TTS**: Polina (uk-UA-PolinaNeural), Ostap (uk-UA-OstapNeural), en-US-GuyNeural - **NODA1**: залишається `STT_PROVIDER=none` / `TTS_PROVIDER=none` (не заважає роутингу) ### Phase 2 (MLX upgrade — опційний) Встановити `STT_PROVIDER=mlx_whisper` та/або `TTS_PROVIDER=mlx_kokoro` в docker-compose коли: - готовий ffmpeg або чітко обмежені формати - потрібний якісніший локальний TTS замість edge-tts - NODA2 Apple Silicon виграш від MLX --- ## Voice HA (Multi-node routing) — PR1–PR3 ### Архітектура ``` Browser → sofiia-console /api/voice/tts ↓ VOICE_HA_ENABLED=false (default) memory-service:8000/voice/tts ← legacy direct ↓ VOICE_HA_ENABLED=true Router /v1/capability/voice_tts ↓ (caps + scoring) node.{id}.voice.tts.request (NATS) ↓ node-worker (voice semaphore) ↓ memory-service/voice/tts ``` ### NATS Subjects (Voice HA — відокремлені від generic) | Subject | Призначення | |---|---| | `node.{id}.voice.tts.request` | Voice TTS offload (окремий semaphore) | | `node.{id}.voice.llm.request` | Voice LLM inference (голосові guardrails) | | `node.{id}.voice.stt.request` | Voice STT transcription | **Сумісність:** generic subjects (`node.{id}.tts.request` etc.) — незмінні. ### Capability Flags Node Worker `/caps` повертає: ```json { "capabilities": { "tts": true, "voice_tts": true, "voice_llm": true, "voice_stt": true }, "voice_concurrency": { "voice_tts": 4, "voice_llm": 2, "voice_stt": 2 } } ``` `voice_tts=true` лише коли `TTS_PROVIDER != none` **і** NATS subscription активна. NCS агрегує ці флаги через `_derive_capabilities()`. ### Router Endpoints | Endpoint | Дедлайн | Суб'єкт | |---|---|---| | `POST /v1/capability/voice_tts` | 3000ms | `node.{id}.voice.tts.request` | | `POST /v1/capability/voice_llm` | 9000ms (fast) / 12000ms (quality) | `node.{id}.voice.llm.request` | | `POST /v1/capability/voice_stt` | 6000ms | `node.{id}.voice.stt.request` | Response headers: `X-Voice-Node`, `X-Voice-Mode` (local|remote), `X-Voice-Cap`. ### Scoring ``` score = wait_ms + rtt_ms + p95_ms + mem_penalty - local_bonus mem_penalty = 300 if mem_pressure == "high" local_bonus = VOICE_PREFER_LOCAL_BONUS (default 200ms) ``` Якщо `score_local <= score_best_remote + LOCAL_THRESHOLD_MS` → вибирається локальна нода. ### BFF Feature Flag ```yaml # docker-compose.node2-sofiia.yml VOICE_HA_ENABLED: "false" # default — legacy direct path VOICE_HA_ROUTER_URL: "http://router:8000" # Router для HA offload ``` Активація: `VOICE_HA_ENABLED=true` + rebuild `sofiia-console`. Деактивація: `VOICE_HA_ENABLED=false` — повертається до direct memory-service. ### Метрики (Prometheus) **node-worker** (`/prom_metrics`): - `node_worker_voice_jobs_total{cap,status}` - `node_worker_voice_inflight{cap}` - `node_worker_voice_latency_ms{cap}` (histogram) **router** (`/fabric_metrics`): - `fabric_voice_capability_requests_total{cap,status}` - `fabric_voice_offload_total{cap,node,status}` - `fabric_voice_breaker_state{cap,node}` (1=open) - `fabric_voice_score_ms{cap}` (histogram) ### Контракт: No Silent Fallback - Будь-який fallback (busy, broken, timeout) логує `WARNING` + інкрементує Prometheus counter - `TOO_BUSY` включає `retry_after_ms` hint для Router failover - Circuit breaker per `node+voice_cap` — не змішується з generic CB ### Тести `tests/test_voice_ha.py` — 28 тестів: - Node Worker voice caps + semaphore isolation - Router fabric_metrics voice helpers - BFF `VOICE_HA_ENABLED` feature flag - Voice scoring logic (local prefer, mem penalty, remote wins when saturated) - No silent fallback invariants