diff --git a/.env.example b/.env.example index 7a305c2..61273c9 100644 --- a/.env.example +++ b/.env.example @@ -20,14 +20,14 @@ MOTEUR_API_KEY= MCP_TOKEN_KEY= # [Audit 2026-07-09, #T206] Racine autorisée pour les outils write_file/read_file -# (tools/system.py). Par défaut : dossier PARENT de moteur_agents/ (ex. H:\AuxFilsDesIdees +# (tools/system.py). Par défaut : dossier PARENT de moteur_agents/ (ex. . # en dev, parent du dossier de déploiement en prod). Ne définir que si cette topologie # ne correspond pas à votre déploiement (ex. Deck avec une racine de travail différente). MOTEUR_WORKSPACE_ROOT= # Origines CORS autorisées (séparées par des virgules). Si vide → CORS ouvert (*) # SANS credentials. Renseigner pour activer les credentials de façon sûre. -# Ex. : http://192.168.1.x:8000,http://localhost:8000,http://localhost:5173 +# Ex. : http://192.168.1.10:8000,http://localhost:8000,http://localhost:5173 MOTEUR_CORS_ORIGINS= # ── Plugins & Tool-Maker (exécution de code généré — fail-closed) ── @@ -39,6 +39,24 @@ MOTEUR_ENABLE_PLUGINS=0 # + validés en sandbox mais JAMAIS persistés. 1 pour autoriser l'auto-écriture. MOTEUR_ENABLE_TOOL_MAKER=0 +# ── Confidentialité (#T337) ── +# Mode « inférence locale uniquement » : quand la valeur est `local_only`, AUCUNE +# requête ne part vers un provider cloud — seuls les modèles servis par un provider +# LOCAL (Ollama, LM Studio, Deck Edge AI) sont retenus. Si aucun modèle local n'est +# disponible dans le tier demandé, l'appel ÉCHOUE explicitement (fail-closed) : le +# moteur ne retombe JAMAIS en silence sur le cloud. Vide (défaut) = comportement +# historique inchangé. +# MOTEUR_PRIVACY_LEVEL=local_only +MOTEUR_PRIVACY_LEVEL= + +# ── Concurrence d'exécution (#T344) ── +# Plafond d'exécutions LOURDES simultanées (pipeline complet, Engine partagé). +# Défaut : 3 (IHM + vocal + un DAG de fond). Les chemins légers (discussion, +# commandes HA, small-talk) ne consomment aucun créneau de ce plafond. +# - 1 : une seule exécution lourde à la fois (comportement historique). +# - >3 : réservé aux machines plus puissantes que le Deck. +MOTEUR_EXECUTION_MAX_CONCURRENCY=3 + # ── Providers LLM (chaque clé active le provider dans la cascade Elo) ── # Google Gemini — Free Tier (AI Studio). Jusqu'à 6 clés pour la rotation KeyPool # (contournement des quotas). Sert AUSSI aux embeddings RAG (gemini-embedding). @@ -81,13 +99,13 @@ DASHSCOPE_API_KEY= # vers une exécution locale). MOTEUR_TOOLMAKER_PAT= # Dépôt cible des branches éphémères toolmaker/validate-* (défaut ci-dessous). -MOTEUR_TOOLMAKER_REPO=Axellum/moteur_agents +MOTEUR_TOOLMAKER_REPO=Axellum/vromvrom-engine # Délai max d'attente du verdict du runner (secondes, défaut 300). MOTEUR_TOOLMAKER_TIMEOUT_S=300 # ── Home Assistant ── # URL de l'instance HA (HASS_URL et HA_URL sont tous deux reconnus). -HASS_URL=http://192.168.1.x:8123 +HASS_URL=http://192.168.1.10:8123 # Token longue durée HA (profil HA → Jetons d'accès). Requis pour la domotique. HASS_TOKEN= # ── TLS Home Assistant (P0-1.5) ── @@ -109,3 +127,20 @@ LANGFUSE_HOST= # ── Divers ── # Taux de conversion EUR→USD pour l'affichage des coûts GCP (ex: 1.08). EUR_USD_RATE= + +# [#T316] Plafond de sortie par défaut des providers OpenAI-compatibles, utilisé +# quand un modèle est absent du catalogue (models_registry.db) ou à +# `context_output` nul. Sinon, le plafond est dérivé du `context_output` du +# catalogue. Un `max_tokens` fourni par l'appelant n'est JAMAIS écrasé. +# Défaut : 8192. Exemple : MOTEUR_MAX_OUTPUT_TOKENS=16384 +MOTEUR_MAX_OUTPUT_TOKENS=8192 + +# ── Vocal / fast-path (rapidité = chez vous) ── +# Ordre câblé dans services/pipeline_service.py : +# 1. gpt-oss-120b (Cerebras, ~430 ms TTFT) → CEREBRAS_API_KEY +# 2. LLM local (Ollama / llama.cpp OpenAI-compat) si l'hôte décroche +# 3. gemini-3.5-flash-free → GEMINI_API_KEY +# 4. deepseek-chat → DEEPSEEK_API_KEY +# Un provider sans clé ou hors-ligne est sauté (sonde d'hôte ~ms, pas 2,6 s). +# Entité météo Zero-LLM (défaut weather.home). Surcharger si besoin : +HA_WEATHER_ENTITY=weather.home diff --git a/AGENTS.md b/AGENTS.md index 17cb810..7902504 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — tab5-engine -Tu travailles **dans ce dépôt**. Le parapluie `H:\AuxFilsDesIdees\contexte_ia\` est le journal d'écosystème (Tab5, HA, sessions). Ici = source de vérité du moteur. +Tu travailles **dans ce dépôt**. Le parapluie `.\contexte_ia\` est le journal d'écosystème (Tab5, HA, sessions). Ici = source de vérité du moteur. ## Lire, dans cet ordre diff --git a/README.fr.md b/README.fr.md index 73e0576..9ebe458 100644 --- a/README.fr.md +++ b/README.fr.md @@ -15,7 +15,7 @@ *Construit pour Home Assistant · Fonctionne avec n'importe quel projet* > ⚠️ **État du projet : En développement actif** -> Ce moteur est un outil personnel pointu partagé avec la communauté. Bien que l'architecture backend (DAG, async, routage Elo) soit robuste et utilisée quotidiennement, **l'interface web (IHM) est actuellement en construction**. Attendez-vous à des aspérités. C'est un projet expérimental destiné aux makers. +> Outil personnel partagé avec la communauté. Le backend (DAG, async, routage Elo) tourne tous les jours. **L'IHM React (`ihm-v2/`) est dans le dépôt** — un `npm run build` et `gui_server.py` la sert sur `/`. Attendez-vous à des aspérités. 🇬🇧 **[English version → README.md](README.md)**  ·  🗺️ **[Quels LLMs utiliser ? → STRATEGIES.fr.md](STRATEGIES.fr.md)** @@ -99,8 +99,8 @@ vromvrom-engine/ ├── services/ # Logique métier découplée du HTTP ├── plugins/ # Plugins custom (chargement dynamique) ├── workflows/ # Définitions de workflows JSON (graphes) -├── static/ # IHM HTML/JS/CSS (glassmorphism, legacy) -├── ihm-v2/ # Nouvelle IHM React/Vite/TS (en cours, remplacera static/) +├── static/ # IHM HTML/JS (repli si ihm-v2 n'est pas buildée) +├── ihm-v2/ # IHM React/Vite/TS (Chat, HA, catalogue LLM, Prompt Studio…) ├── tests/ # 60 fichiers de tests pytest └── docs/ # Documentation architecture ``` @@ -168,8 +168,9 @@ cp config.example.json config.json # Optionnel : ajuster les modèles par défaut # 6. Lancer +# (optionnel) IHM React : cd ihm-v2 && npm install && npm run build python gui_server.py -# → Dashboard disponible sur http://localhost:8000 +# → Dashboard sur http://localhost:8000 ``` ### Configuration minimale (`.env`) @@ -192,14 +193,16 @@ Tous les autres providers (DeepSeek, Anthropic, Mistral...) sont **optionnels** ### Via le Dashboard Web -Ouvrir `http://localhost:8000` après `python gui_server.py`. +```bash +cd ihm-v2 && npm install && npm run build # → ihm-v2/dist/ +python gui_server.py # http://localhost:8000 +``` + +Sans `ihm-v2/dist`, le moteur retombe sur l'IHM legacy `static/` (aussi sous `/v1`). + +Dev avec HMR : `cd ihm-v2 && npm run dev` (Vite `:5173`, proxy `/api` vers `:8000`). -Le dashboard expose : -- **Onglet Chat** : interface conversationnelle avec streaming token-par-token -- **Onglet Workflows** : éditeur visuel des graphes d'exécution -- **Onglet Modèles** : catalogue des LLMs avec scores Elo temps-réel -- **Onglet Supervision** : monitoring des workers Swarm -- **Onglet Données** : exploration des bases SQLite +L'IHM React inclut Chat (SSE + outils), Home Assistant / vocal, catalogue LLM, Prompt Studio, observabilité et configuration. ### Via l'API REST diff --git a/README.md b/README.md index 9c5b430..a63064a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ *Built for Home Assistant · Works with any project* > ⚠️ **Project Status: Active Development** -> This engine is a personal tool being shared with the community. While the core backend architecture (DAG, async, Elo routing) is robust and used daily, the **Web Dashboard (HMI) is currently under construction**. Expect rough edges. It is highly experimental and built for makers. +> Personal tool shared with the community. Backend (DAG, async, Elo routing) is used daily. The **React dashboard (`ihm-v2/`)** is included — build it once, then `gui_server.py` serves it at `/`. Expect rough edges. 🇫🇷 **[Version française disponible → README.fr.md](README.fr.md)**  ·  🗺️ **[Which LLMs to use? → STRATEGIES.md](STRATEGIES.md)** @@ -102,8 +102,8 @@ vromvrom-engine/ ├── services/ # Business logic decoupled from HTTP ├── plugins/ # Custom plugins (dynamic loading) ├── workflows/ # JSON workflow definitions (graphs) -├── static/ # HTML/JS/CSS UI (glassmorphism, legacy dashboard) -├── ihm-v2/ # New React/Vite/TS dashboard (in progress, will replace static/) +├── static/ # HTML/JS/CSS UI (glassmorphism, fallback if ihm-v2 is not built) +├── ihm-v2/ # React/Vite/TS dashboard (Chat, HA, LLM registry, Prompt Studio…) ├── tests/ # 60 pytest files └── docs/ # Architecture documentation ``` @@ -171,8 +171,9 @@ cp config.example.json config.json # Optional: adjust default models # 6. Launch +# (optional) React UI: cd ihm-v2 && npm install && npm run build python gui_server.py -# → Dashboard available at http://localhost:8000 +# → Dashboard at http://localhost:8000 ``` ### Minimal configuration (`.env`) @@ -195,14 +196,16 @@ All other providers (DeepSeek, Anthropic, Mistral...) are **optional** — the e ### Web Dashboard -Open `http://localhost:8000` after `python gui_server.py`. +```bash +cd ihm-v2 && npm install && npm run build # → ihm-v2/dist/ +python gui_server.py # http://localhost:8000 +``` + +If `ihm-v2/dist` is missing, the engine falls back to the legacy UI in `static/` (also under `/v1`). + +Dev with HMR: `cd ihm-v2 && npm run dev` (Vite `:5173`, proxies `/api` to `:8000`). -The dashboard exposes: -- **Chat tab**: conversational interface with token-by-token streaming -- **Workflows tab**: visual workflow graph editor -- **Models tab**: LLM catalog with real-time Elo scores -- **Swarm tab**: distributed worker monitoring -- **Data tab**: SQLite database explorer +The React dashboard includes Chat (SSE + tools), Home Assistant / voice, LLM registry, Prompt Studio, observability, and config. ### Via the REST API diff --git a/agents/dreamer_agent.py b/agents/dreamer_agent.py index 0a70114..30427f9 100644 --- a/agents/dreamer_agent.py +++ b/agents/dreamer_agent.py @@ -20,6 +20,7 @@ import json import logging import os +import subprocess import time from datetime import datetime, timedelta from typing import Any @@ -500,6 +501,60 @@ def _save_report(report: dict) -> str: # DreamCoder — traitement d'une tâche + boucle de drainage à durée réglable # ────────────────────────────────────────────────────────────────── +def _pousser_branche_tache(repo_root: str, branch_name: str, + timeout_s: float = 90.0) -> dict[str, Any]: + """ + Pousse la branche 'task/*' vers `origin` — best effort, JAMAIS bloquant. + + Le commit local EST la valeur produite par la tâche. Un push impossible + (clé de déploiement en lecture seule sur le Deck, remote volontairement + neutralisé sur la prod, réseau coupé) ne doit pas retransformer une tâche + réussie en échec : on journalise la raison et la branche reste récoltable + par `scripts/recolte_dreamcoder.py` depuis un poste qui a le droit d'écrire. + + Deux garde-fous, tous deux nés d'un blocage réel : + - `GIT_TERMINAL_PROMPT=0` — le clone dédié du Deck a un `origin` en HTTPS + sans identifiants ; sans ça, git attendrait une saisie qui ne viendra + jamais dans un service systemd. + - timeout dur — le push est dans la fenêtre `dreamcoder_max_cycle_minutes`, + qui est le budget de TOUTES les tâches du cycle : un push qui pend + mangerait le temps des suivantes. + + :return: {"pushed": bool, "raison": str, "detail": str} + """ + from tools.git_safety import _run_git + + # Le dépôt de PROD du Deck neutralise volontairement son push + # (`no-push://le-deck-est-une-cible-de-deploiement`, garde-fou du 07/08 : + # 23 clés en clair y étaient suivies). Détecté ici pour rendre une raison + # lisible plutôt qu'une erreur de transport opaque. + code, push_url, _ = _run_git(["remote", "get-url", "--push", "origin"], cwd=repo_root) + if code != 0 or not push_url: + return {"pushed": False, "raison": "remote_absent", "detail": push_url or ""} + if push_url.startswith("no-push://"): + return {"pushed": False, "raison": "remote_no_push", "detail": push_url} + + env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"} + try: + res = subprocess.run( + ["git", "push", "--set-upstream", "origin", branch_name], + cwd=repo_root, capture_output=True, text=True, + timeout=timeout_s, env=env, + ) + except subprocess.TimeoutExpired: + return {"pushed": False, "raison": "timeout", "detail": f"{timeout_s}s"} + except Exception as e: # transport cassé, git absent, permissions… + return {"pushed": False, "raison": "erreur", "detail": str(e)[:300]} + + if res.returncode == 0: + return {"pushed": True, "raison": "ok", "detail": ""} + return { + "pushed": False, + "raison": "refuse", + "detail": ((res.stderr or "") + (res.stdout or "")).strip()[:300], + } + + async def _process_one_dreamcoder_task(task: dict, pa_config: dict, provider: str, orig_branch: str, bg: Any) -> dict[str, Any]: """ @@ -519,6 +574,7 @@ async def _process_one_dreamcoder_task(task: dict, pa_config: dict, provider: st result: dict[str, Any] = { "task_id": task_id, "title": task["title"], "status": None, "branch": None, "cost_usd": 0.0, "tokens_used": 0, "error": None, + "push": {"pushed": False, "raison": "non_tente", "detail": ""}, } logger.info(f"[DREAMER] [DreamCoder] Tâche trouvée : ID {task_id} - '{task['title']}'") logger.info(f"[DREAMER] [DreamCoder] Provider alloué : {provider}") @@ -545,7 +601,6 @@ async def _process_one_dreamcoder_task(task: dict, pa_config: dict, provider: st "gemini-cli-abo": "gemini-3.5-flash-high-cli", "claude-cli-abo": "claude-sonnet-4.6-thinking-cli", "cerebras-free": "gpt-oss-120b", - "dashscope-coding": "dashscope/qwen3-coder-next", "cohere-free": "command-r-plus-08-2024", "mistral-free": "open-mistral-nemo", "gemini-free": "gemini-3.5-flash-free", @@ -598,8 +653,15 @@ async def _process_one_dreamcoder_task(task: dict, pa_config: dict, provider: st if branch_name.startswith("Erreur"): logger.error(f"[DREAMER] [DreamCoder] Impossible de préparer la branche Git : {branch_name}") - await update_task_status(task_id, 'failed', error_message=branch_name) - result["status"] = "failed" + # `retries` était laissé à 0 sur ce chemin : la tâche n'atteignait + # jamais 'abandoned' après 3 échecs, et comme 'failed' n'est pas repris + # par get_next_task(), elle restait bloquée sans que rien ne le dise. + # Constaté sur le Deck : deux tâches 'failed' / retries=0 depuis juillet. + next_retries = task.get("retries", 0) + 1 + statut = "abandoned" if next_retries >= 3 else "failed" + await update_task_status(task_id, statut, error_message=branch_name, + retries=next_retries) + result["status"] = statut result["error"] = branch_name return result @@ -608,6 +670,7 @@ async def _process_one_dreamcoder_task(task: dict, pa_config: dict, provider: st result["branch"] = branch_name from core.app_state import get_app_state as _get_app_state + from core.fenetres_execution import duree_enveloppe_dreamcoder_s, duree_tache_dreamcoder_s from services.pipeline_service import run_full_pipeline # [#T202] Propager la racine de travail au pipeline (hooks Git/YAML/doc de @@ -633,6 +696,10 @@ async def sse_callback(event_type, data, engine_inst): try: logger.info("[DREAMER] [DreamCoder] Démarrage du pipeline de tâche...") + # Les DEUX bornes viennent de core/fenetres_execution.py : sans + # `timeout_seconds`, le pipeline reprenait son défaut de 120 s hérité du + # chemin interactif, et l'enveloppe de 600 s ci-dessous n'était que + # décorative — le vrai plafond d'une tâche de nuit était de 2 minutes. pipeline_result = await asyncio.wait_for( run_full_pipeline( user_prompt=work_prompt, @@ -640,9 +707,10 @@ async def sse_callback(event_type, data, engine_inst): initial_payload=initial_payload, starting_agent=starting_agent, on_event_callback=sse_callback, - config=temp_config + config=temp_config, + timeout_seconds=duree_tache_dreamcoder_s(), ), - timeout=600.0 # 10 minutes timeout + timeout=duree_enveloppe_dreamcoder_s(), ) logger.info(f"[DREAMER] [DreamCoder] Pipeline terminé avec statut: {pipeline_result.get('status')}") @@ -686,6 +754,19 @@ async def sse_callback(event_type, data, engine_inst): # Générer le diff _, diff_out, _ = await asyncio.to_thread(_run_git, ["diff", "HEAD~1..HEAD"], repo_root) + # Sortir le travail du Deck : la branche est poussée vers origin dès + # qu'elle porte un commit. Best effort — un refus (deploy key en + # lecture seule) laisse la tâche 'completed' et la branche récoltable. + push_info = await asyncio.to_thread(_pousser_branche_tache, repo_root, branch_name) + result["push"] = push_info + if push_info["pushed"]: + logger.info(f"[DREAMER] [DreamCoder] Branche poussée sur origin : {branch_name}") + else: + logger.warning( + f"[DREAMER] [DreamCoder] Branche NON poussée ({push_info['raison']}) : " + f"{branch_name} — {push_info['detail'][:160]}" + ) + # Créer le fichier de rapport de résultats results_dir = os.path.join(_ENGINE_ROOT, "checkpoints", "dreamcoder_results") os.makedirs(results_dir, exist_ok=True) @@ -703,7 +784,8 @@ async def sse_callback(event_type, data, engine_inst): "summary": pipeline_result.get("response", ""), "timestamp": time.time(), "tokens_used": tokens_used, - "cost_usd": cost + "cost_usd": cost, + "push": push_info }, f, indent=2, ensure_ascii=False) # Mettre à jour la base @@ -776,7 +858,8 @@ async def _run_dreamcoder_drain_loop(pa_config: dict) -> dict[str, Any]: results: dict[str, Any] = { "processed": 0, "success": 0, "failed": 0, "abandoned": 0, "paused": 0, "total_cost_usd": 0.0, "total_tokens": 0, - "branches_awaiting_review": [], "tasks": [], "stopped_reason": None, + "branches_awaiting_review": [], "branches_pushed": [], + "tasks": [], "stopped_reason": None, } # [#T202] Dépôt de travail du cycle (clone dédié si configuré). @@ -832,6 +915,11 @@ async def _run_dreamcoder_drain_loop(pa_config: dict) -> dict[str, Any]: results["success"] += 1 if task_result.get("branch"): results["branches_awaiting_review"].append(task_result["branch"]) + # Distinguer « travail fait » de « travail sorti du Deck » : une + # branche non poussée demande une récolte manuelle, et le rapport + # de cycle est le seul endroit où ça se voit. + if (task_result.get("push") or {}).get("pushed"): + results["branches_pushed"].append(task_result["branch"]) elif status in ("failed", "abandoned", "paused"): results[status] += 1 # Les tâches 'failed'/'abandoned' sortent naturellement de 'pending' : @@ -1054,6 +1142,7 @@ async def run_dreamer_cycle(pa_config: dict) -> dict[str, Any]: "total_cost_usd": round(drain_results["total_cost_usd"], 6), "total_tokens": drain_results["total_tokens"], "branches_awaiting_review": drain_results["branches_awaiting_review"], + "branches_pushed": drain_results["branches_pushed"], "stopped_reason": drain_results["stopped_reason"], } if drain_results["processed"] > 0: diff --git a/agents/executor.py b/agents/executor.py index f347f24..030d947 100644 --- a/agents/executor.py +++ b/agents/executor.py @@ -60,8 +60,8 @@ def __init__(self, llm_gateway: LLMGateway, tool_registry: ToolRegistry, provide is_windows = (sys.platform == 'win32' or os.name == 'nt') if is_windows: os_rules = """2. COMPATIBILITÉ WINDOWS : La machine hôte tourne sous Windows. N'utilise jamais de commandes terminal Unix comme 'ls', 'grep' ou 'cat' via 'run_terminal_command'. Utilise à la place les outils de manipulation de fichier de Python ('read_file', 'write_file') ou des commandes Windows natives ('dir', 'findstr', 'type'). -3. SÉCURITÉ DU SYSTÈME : Ne modifie jamais de fichiers système Windows (comme C:\\Windows ou C:\\Windows\\System32). Tout fichier créé ou modifié doit l'être uniquement dans le répertoire de l'application ou du workspace de l'utilisateur (e:\\AuxFilsDesIdees). -4. CHEMINS : Le workspace principal est 'e:\\AuxFilsDesIdees'. Le tab5-engine (code Python) est dans 'e:\\AuxFilsDesIdees\\moteur_agents\\'. Les fichiers core/ sont donc dans 'e:\\AuxFilsDesIdees\\moteur_agents\\core\\'. Utilise TOUJOURS des chemins absolus.""" +3. SÉCURITÉ DU SYSTÈME : Ne modifie jamais de fichiers système Windows (comme C:\\Windows ou C:\\Windows\\System32). Tout fichier créé ou modifié doit l'être uniquement dans le répertoire de l'application ou du workspace de l'utilisateur. +4. CHEMINS : Le workspace est la racine du dépôt. Les fichiers core/ sont dans core/. Utilise TOUJOURS des chemins absolus.""" else: os_rules = """2. COMPATIBILITÉ LINUX : La machine hôte tourne sous Linux (Alpine). N'exécute pas de commandes Windows comme 'dir', 'type', 'findstr' via 'run_terminal_command'. Utilise à la place des commandes Unix standard comme 'ls', 'cat', 'grep', ou de préférence les outils intégrés Python. 3. SÉCURITÉ DU SYSTÈME : Ne tente pas de modifier les répertoires système protégés. Tout fichier créé ou modifié doit l'être uniquement dans le workspace (/config). @@ -499,23 +499,33 @@ async def _execute_react_loop( vus_dans_le_tour: set[tuple] = set() nb_ecartes = 0 nb_doublons = 0 + nb_decodages = 0 for tool_call in tool_calls: func_name = tool_call["function"]["name"] tool_call_id = tool_call.get("id", "call_123") kwargs_str = tool_call["function"]["arguments"] + + # [#T343-A] JSON d'arguments illisible : l'outil ne part + # JAMAIS à vide. Avant, `json.loads` échouait → `kwargs = {}` + # → l'outil était exécuté SANS ses arguments (set_temperature() + # sans température, write_file() sans chemin), en silence. + # On retient l'erreur de décodage (renvoyée au modèle) et la + # clé de déduplication repose alors sur la chaîne brute. try: kwargs = json.loads(kwargs_str) + erreur_decodage = None except Exception as e: - res = f"Erreur de décodage des arguments JSON : {e}" kwargs = {} + erreur_decodage = str(e) # Clé de déduplication : arguments normalisés (l'ordre des # clés JSON ne doit pas créer de faux doublons), chaîne - # brute si le JSON est invalide. - try: - cle_appel = (func_name, json.dumps(kwargs, sort_keys=True, default=str)) - except Exception: + # brute si le JSON est invalide (deux malformés différents + # restent donc deux appels différents). + if erreur_decodage is not None: cle_appel = (func_name, kwargs_str) + else: + cle_appel = (func_name, json.dumps(kwargs, sort_keys=True, default=str)) if cle_appel in vus_dans_le_tour: nb_doublons += 1 @@ -524,6 +534,13 @@ async def _execute_react_loop( f"{json.dumps(kwargs, ensure_ascii=False)}), déjà exécuté " "avec exactement les mêmes arguments dans ce tour" ) + elif erreur_decodage is not None: + # [#T343-A] Arguments illisibles : on ne déduplique pas + # (chaîne brute déjà distincte), on n'exécute pas, on + # nomme l'erreur de décodage. L'appel ne consomme pas la + # borne MAX_TOOL_CALLS_PER_TURN des appels légitimes. + nb_decodages += 1 + raison = f"arguments JSON invalides, outil non exécuté : {erreur_decodage}" elif len(vus_dans_le_tour) >= MAX_TOOL_CALLS_PER_TURN: raison = f"borne de {MAX_TOOL_CALLS_PER_TURN} appels d'outil par tour atteinte" else: @@ -584,7 +601,8 @@ async def _execute_react_loop( if nb_ecartes: logger.warning( f"[{self.name}]{prefix} {nb_ecartes} appel(s) d'outil écarté(s) ce tour " - f"({nb_doublons} doublon(s), {nb_ecartes - nb_doublons} au-delà de la " + f"({nb_doublons} doublon(s), {nb_decodages} JSON illisible(s), " + f"{nb_ecartes - nb_doublons - nb_decodages} au-delà de la " f"borne de {MAX_TOOL_CALLS_PER_TURN})" ) diff --git a/agents/planner.py b/agents/planner.py index 7f24f9b..2e6792b 100644 --- a/agents/planner.py +++ b/agents/planner.py @@ -205,6 +205,36 @@ async def invoke(self, payload: TaskPayload) -> StateUpdate: "required": ["type", "valeur"] }, "description": "OBLIGATOIRE. Vérifications mécaniques prouvant que l'objectif est atteint. Liste vide [] si l'objectif n'est pas vérifiable mécaniquement (question, analyse, discussion) — mais la clé doit toujours être présente." + }, + # [Incident du 17/08] Substitution TRACÉE d'un critère du contrat + # d'origine devenu impossible parce que le plan correctif a + # légitimement changé d'approche (autre nom de fichier, autre + # chemin). Jamais de remplacement silencieux : chaque + # substitution doit être déclarée ici, elle sera journalisée, + # et elle n'est honorée que si le critère remplaçant figure + # dans 'criteres_acceptation' et n'était pas déjà satisfait + # avant le travail. Un plan qui ne substitue rien laisse []. + "substitutions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "origine": { + "type": "object", + "description": "Le critère du contrat d'origine remplacé (mêmes champs qu'un critère).", + }, + "remplacant": { + "type": "object", + "description": "Le critère qui le remplace — il DOIT aussi figurer dans criteres_acceptation.", + }, + "motif": { + "type": "string", + "description": "Pourquoi le critère d'origine est devenu impossible (ex: changement de chemin décidé par la correction).", + }, + }, + "required": ["origine", "remplacant"], + }, + "description": "Facultatif. Substitutions déclarées de critères du contrat d'origine, chacune tracée et journalisée. Liste vide par défaut.", } }, "required": ["plan", "criteres_acceptation"] @@ -339,8 +369,39 @@ def get_stage_id(t_id: str, visited: set) -> int: # [#T253] Contrat d'acceptation transporté dans les metadata du # StateUpdate : la boucle de revue le retrouvera dans l'historique. - from core.acceptance_contract import CLE_CONTRAT, normaliser_criteres + from core.acceptance_contract import ( + CLE_CONTRAT, + CLE_ETAT_INITIAL, + CLE_SUBSTITUTIONS, + constater_etat_initial, + normaliser_criteres, + ) criteres = normaliser_criteres(response_json.get("criteres_acceptation")) + + # [Incident du 17/08] Constat de l'état des critères fichier À LA + # POSE DU PLAN, donc avant tout travail du lot : à l'évaluation, + # tout critère déjà satisfait à cet instant sera écarté du verdict + # comme « satisfait d'avance » — un critère vrai avant le travail + # n'est pas une preuve de travail. C'est ce constat qui rend + # inopérant un contrat correctif tautologique du type « vérifier + # qu'un fichier préexistant est accessible ». + etat_initial = constater_etat_initial(criteres) if criteres else {} + + # [Incident du 17/08] Substitutions DÉCLARÉES par ce plan : elles + # voyagent avec le contrat et seront validées puis journalisées par + # la boucle de revue — jamais appliquées silencieusement ici. + substitutions_declarees = [ + s for s in (response_json.get("substitutions") or []) + if isinstance(s, dict) and s.get("origine") and s.get("remplacant") + ] + for substitution in substitutions_declarees: + logger.warning( + "[PLANNER] [CONTRAT] Substitution déclarée par le plan : " + f"{substitution['origine']!r} → {substitution['remplacant']!r} " + f"(motif : {substitution.get('motif') or 'non renseigné'}). " + "Elle sera validée et tracée à l'évaluation." + ) + if criteres: logger.info( f"[PLANNER] [T253] Contrat d'acceptation : {len(criteres)} critère(s) — " @@ -365,7 +426,11 @@ def get_stage_id(t_id: str, visited: set) -> int: CLE_CONTRAT: [ {"type": c.type, "valeur": c.valeur, "attendu": c.attendu, "description": c.description} for c in criteres - ] + ], + # [Incident du 17/08] Le constat d'avant-travail et les + # substitutions déclarées voyagent avec le contrat. + CLE_ETAT_INITIAL: etat_initial, + CLE_SUBSTITUTIONS: substitutions_declarees, } if criteres else {}, ) diff --git a/api/routes/agents.py b/api/routes/agents.py index 294862e..80d1f72 100644 --- a/api/routes/agents.py +++ b/api/routes/agents.py @@ -16,7 +16,7 @@ from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from core.app_state import broadcast_event, get_app_state +from core.app_state import _cle_execution, broadcast_event, get_app_state from core.auth import optional_auth from core.llm_gateway import LLMGateway, load_config from core.serializers import global_state_to_dict @@ -29,7 +29,6 @@ build_ha_mode_failure_response, execute_ha_service, get_execute_timeout, - match_ha_command, prompt_has_domotic_action, resolve_ha_command_for_execute, should_block_full_pipeline, @@ -77,7 +76,7 @@ def _ha_conversational_response(prompt: str) -> str | None: if words & _HA_GREETING_TOKENS or any( p in norm for p in ("comment vas", "ca va", "ça va", "qui es tu", "qui es-tu") ): - return "Bonjour Axel, que veux-tu contrôler ?" + return "Bonjour, que veux-tu contrôler ?" if words & _HA_THANKS_TOKENS or norm.startswith("merci"): return "De rien. Que veux-tu contrôler ?" @@ -90,6 +89,10 @@ def _ha_conversational_response(prompt: str) -> str | None: class RunRequestBody(BaseModel): objective: str + # [#T324] Clé de concurrence : session_id fourni par le client. À défaut, + # la clé d'exécution retombe sur la source de la requête (voir + # core.app_state._cle_execution). + session_id: str | None = None class ExecuteRequestBody(BaseModel): @@ -104,6 +107,9 @@ class ExecuteRequestBody(BaseModel): source: dict = {} # Ex: {"type": "tab5", "mode": "ha", "tts_enabled": true} tier: str | None = None model: str | None = None + # [#T324] Clé de concurrence : session_id fourni par le client. À défaut, + # la clé d'exécution retombe sur la source de la requête. + session_id: str | None = None @router.post("/api/run") @@ -115,16 +121,14 @@ async def run_task(body: RunRequestBody): state = get_app_state() session_id = f"bg_{uuid.uuid4().hex[:8]}" - async with state.execution_lock: - if state.execution_state.get("status") == "running": - raise HTTPException(status_code=400, detail="Une exécution de tâche est déjà en cours.") - state.execution_state.update({ - "status": "running", - "objective": body.objective, - "session_id": session_id, - "engine_state": None, - "error_message": None - }) + # [#T324] Clé de concurrence par session : le verrou n'est plus global. + # Deux DAG de sessions DIFFÉRENTES peuvent tourner en parallèle ; une même + # session ne s'exécute jamais deux fois (409 doublon) et le plafond global + # de concurrence est respecté. + execution_key = _cle_execution(body.session_id, None) + refus = await state.begin_execution(execution_key, body.objective) + if refus is not None: + raise HTTPException(status_code=refus["status_code"], detail=refus["detail"]) # Enregistrer immédiatement le début de session en BDD try: @@ -137,11 +141,13 @@ async def _on_event(event_type: str, data: Any, engine=None): """Callback SSE pour diffusion temps réel.""" state.execution_state["engine_state"] = global_state_to_dict(engine.state) if engine else None if event_type == "orchestration_completed": - state.execution_state["status"] = data.get("status", "success") + await state.set_execution_status(execution_key, data.get("status", "success")) await broadcast_event(event_type, data) from services.pipeline_service import run_engine_background - asyncio.create_task(run_engine_background(body.objective, _on_event, session_id=session_id)) + asyncio.create_task(run_engine_background( + body.objective, _on_event, session_id=session_id, execution_key=execution_key + )) return { "message": "Tâche démarrée en arrière-plan.", "status": "running", @@ -156,7 +162,8 @@ async def _try_ha_state_query_shortcut( ) -> dict | None: """ Question d'état domotique (« la clim est allumée ? », « le volet est ouvert ? », - « il fait combien dans le salon ? »), AVANT le verrou global et le routeur. + « il fait combien dans le salon ? »), AVANT le registre d'exécution et le + routeur. Lecture live HA en Zero-LLM. Doit passer AVANT le court-circuit d'action car « la lumière est allumée ? » contient un marqueur d'action (« allumée ») qui @@ -256,7 +263,8 @@ async def _try_ha_deterministic_shortcut( session_id: str, ) -> dict | None: """ - Court-circuit domotique déterministe, AVANT le verrou global et le routeur. + Court-circuit domotique déterministe, AVANT le registre d'exécution et le + routeur. Tente le match ha_commands.json (exact/fuzzy) + matchers volet/clim/pièce. Si une commande est reconnue, exécute le service HA et renvoie la réponse @@ -324,6 +332,129 @@ async def _try_ha_deterministic_shortcut( return result +async def _handle_discussion_mode( + body: "ExecuteRequestBody", + request_source, + session_id: str, +) -> dict: + """ + Mode Discussion (`mode=chat`) : le tour part vers `vocal_host`, AVANT le + registre d'exécution et AVANT le routeur — même traitement que les trois + court-circuits domotiques ci-dessus. + + Suite de #T324, qui a rendu le verrou par session : le portillon est posé + avant le CHOIX du chemin, or ce chemin-ci ne touche pas l'Engine partagé + (`handle_discussion` construit sa propre passerelle et ne lit que + l'historique de SA conversation). Deux conséquences, toutes deux évitées + en sortant du portillon : + - il consommait un créneau du plafond `MOTEUR_EXECUTION_MAX_CONCURRENCY` + (défaut 3) pendant toute sa durée : trois tours vocaux en vol + suffisaient à refuser un DAG de l'IHM ; + - la clé d'exécution du Tab5 retombe sur son `device_id` faute de + `session_id` : un second tour vocal pendant qu'un premier est en cours + se prenait un 409 « doublon de session ». + + `router.analyze_request()` n'est plus appelé non plus : son résultat était + JETÉ sur ce chemin (le `routing_type` retenu vient de `host_result`, et ni + `initial_payload` ni `starting_agent` n'y servent). + + Retourne TOUJOURS un résultat (réponse ou repli) : `mode=chat` ne redescend + jamais dans la cascade domotique — c'était déjà le cas, les deux sorties de + la branche d'origine étant des `return`. + """ + from core import token_tracker + from core.vocal_host import handle_discussion + + state = get_app_state() + suffix = request_source.get_system_prompt_suffix() + execute_timeout = get_execute_timeout(request_source, "casual_chat") + conv_id = request_source.conversation_id + + log_vocal_request( + session_id=session_id, + user_prompt=body.user_prompt, + source_type=request_source.type.value, + source_mode=request_source.mode.value, + tts_enabled=request_source.tts_enabled, + device_id=request_source.device_id, + ) + logger.info("[EXECUTE] 💬 Mode discussion → vocal_host") + + if conv_id: + from core.vocal_session import record_vocal_turn + record_vocal_turn( + conv_id, "user", body.user_prompt, + source_mode="chat", device_id=request_source.device_id, + ) + + with VocalAuditTimer() as timer: + try: + host_result = await asyncio.wait_for( + handle_discussion( + user_prompt=body.user_prompt, + session_id=session_id, + gateway=LLMGateway(), + token_tracker=token_tracker, + fast_path_cache=state.fast_path_cache, + system_prompt_suffix=suffix, + conversation_id=conv_id, + device_id=request_source.device_id, + tier_override=body.tier, + model_override=body.model, + ), + timeout=execute_timeout, + ) + except Exception as chat_err: + logger.warning(f"[EXECUTE] Discussion fast path échoué : {chat_err}") + result = build_chat_mode_failure_response(session_id) + log_vocal_response( + session_id=session_id, + user_prompt=body.user_prompt, + source_type=request_source.type.value, + source_mode=request_source.mode.value, + routing_type="discussion_chat_failure", + agents_used=["discussion_chat"], + response_text=result["response"], + latency_ms=timer.elapsed_ms, + tts_enabled=request_source.tts_enabled, + device_id=request_source.device_id, + ) + return result + + response_text = host_result.response_text + if conv_id and not host_result.async_job_id: + from core.vocal_session import record_vocal_turn + record_vocal_turn( + conv_id, "assistant", response_text, + source_mode="chat", device_id=request_source.device_id, + ) + agents_used = host_result.agents_used + routing_type = host_result.routing_type + log_source_decision(request_source, routing_type) + + result = _build_fast_path_response(session_id, body.user_prompt, response_text) + result["history"][0]["agent_name"] = agents_used[0] + result["history"][0]["metadata"] = { + "routing_type": routing_type, + "model_tier": "leger", + **host_result.metadata, + } + result["agents_used"] = agents_used + log_vocal_response( + session_id=session_id, + user_prompt=body.user_prompt, + source_type=request_source.type.value, + source_mode=request_source.mode.value, + routing_type=routing_type, + agents_used=agents_used, + response_text=response_text, + latency_ms=timer.elapsed_ms, + tts_enabled=request_source.tts_enabled, + device_id=request_source.device_id, + ) + return result + + @router.post("/api/execute") async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): """ @@ -333,9 +464,9 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): session_id = f"chat_{uuid.uuid4().hex[:10]}" request_source = parse_source(body.source) - # ── Fast paths domotiques (hors verrou global + hors routeur) ── - # Ni la lecture d'état ni une commande reconnue ne doivent bloquer l'IHM - # (verrou global → 409) ou payer le slow-path LLM du routeur. + # ── Fast paths domotiques (hors registre d'exécution + hors routeur) ── + # Ni la lecture d'état ni une commande reconnue ne doivent prendre un + # créneau d'exécution (→ 409) ni payer le slow-path LLM du routeur. if request_source.mode == ModeType.HA: # 1) Question d'état AVANT l'action (« la lumière est allumée ? » contient # « allumée », qui serait sinon pris pour un turn_on). @@ -352,16 +483,20 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): if shortcut is not None: return shortcut - async with state.execution_lock: - if state.execution_state.get("status") == "running": - raise HTTPException( - status_code=409, - detail="Une exécution est déjà en cours. Attendez sa fin ou utilisez /api/stop." - ) - state.execution_state.update({ - "status": "running", "objective": body.user_prompt, - "engine_state": None, "error_message": None, - }) + # ── Mode Discussion : hors registre d'exécution et hors routeur ── + # Ce chemin ne touche pas l'Engine partagé : il n'a donc pas de créneau à + # prendre sur le plafond de concurrence, et le Tab5 n'a pas à se faire + # refuser un second tour vocal en « doublon de session ». L'analyse du + # routeur était de toute façon jetée. Suite de #T324. + if request_source.mode == ModeType.CHAT: + return await _handle_discussion_mode(body, request_source, session_id) + + # [#T344] Le créneau de concurrence n'est PLUS pris ici : il ne protège que + # run_full_pipeline. Les cinq chemins légers (casual_chat, HA déterministe, + # fuzzy, repli LLM, small-talk) retournent avant et ne doivent consommer + # aucun créneau du plafond — sinon trois requêtes légères en vol refuseraient + # un vrai DAG de l'IHM. Le portillon est donc posé juste avant le pipeline. + execution_key = _cle_execution(body.session_id, request_source) suffix = request_source.get_system_prompt_suffix() execute_timeout = get_execute_timeout(request_source, "default") @@ -379,6 +514,33 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): agents_used: list[str] = [] response_text = "" + # [#T344] Vérification précoce du DOUBLON de session, SANS consommer de + # créneau et SANS vérifier le plafond : on ne paie pas `analyze_request` + # (LLM) pour une requête qui sera refusée en doublon de toute façon. Le + # plafond n'est PAS vérifié ici — un chemin léger ne doit jamais être refusé + # parce que des exécutions lourdes saturent le plafond. La protection réelle + # reste `begin_execution`, re-vérifiée atomiquement juste avant le pipeline. + refus_precoce = await state.check_duplicate(execution_key) + if refus_precoce is not None: + raise HTTPException( + status_code=refus_precoce["status_code"], detail=refus_precoce["detail"] + ) + + # Statut terminal de la session, appliqué par le finally (end_execution). + final_status = "success" + final_error = None + final_engine_state = None + + # [#T344] Contre-pression dreamer : le créneau de concurrence ne couvre que + # le pipeline complet, mais le dreamer doit rester silencieux pendant TOUTE + # activité utilisateur, chemins légers compris. Un compteur de requêtes en + # vol, distinct du registre de concurrence, maintient + # execution_state["status"] == "running" tant qu'une requête tourne. + # `creneau_pris` mémorise si begin_execution a réellement été appelé : le + # finally ne libère jamais une entrée qu'on n'a pas créée. + creneau_pris = False + await state.begin_request(objective=body.user_prompt) + try: with VocalAuditTimer() as timer: router_instance = state.get_shared_router() @@ -396,86 +558,8 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): initial_payload.metadata["system_prompt_suffix"] = suffix log_source_decision(request_source, routing_type) - # ── Mode Discussion (chat) : vocal_host → chat sync ou job async ── - if request_source.mode == ModeType.CHAT: - logger.info("[EXECUTE] 💬 Mode discussion → vocal_host") - execute_timeout = get_execute_timeout(request_source, "casual_chat") - conv_id = request_source.conversation_id - if conv_id: - from core.vocal_session import record_vocal_turn - record_vocal_turn( - conv_id, "user", body.user_prompt, - source_mode="chat", device_id=request_source.device_id, - ) - try: - from core import token_tracker - from core.vocal_host import handle_discussion - - host_result = await asyncio.wait_for( - handle_discussion( - user_prompt=body.user_prompt, - session_id=session_id, - gateway=LLMGateway(), - token_tracker=token_tracker, - fast_path_cache=state.fast_path_cache, - system_prompt_suffix=suffix, - conversation_id=conv_id, - device_id=request_source.device_id, - tier_override=body.tier, - model_override=body.model, - ), - timeout=execute_timeout, - ) - response_text = host_result.response_text - if conv_id and not host_result.async_job_id: - from core.vocal_session import record_vocal_turn - record_vocal_turn( - conv_id, "assistant", response_text, - source_mode="chat", device_id=request_source.device_id, - ) - agents_used = host_result.agents_used - routing_type = host_result.routing_type - async with state.execution_lock: - state.execution_state["status"] = "success" - result = _build_fast_path_response(session_id, body.user_prompt, response_text) - result["history"][0]["agent_name"] = agents_used[0] - result["history"][0]["metadata"] = { - "routing_type": routing_type, - "model_tier": "leger", - **host_result.metadata, - } - result["agents_used"] = agents_used - log_vocal_response( - session_id=session_id, - user_prompt=body.user_prompt, - source_type=request_source.type.value, - source_mode=request_source.mode.value, - routing_type=routing_type, - agents_used=agents_used, - response_text=response_text, - latency_ms=timer.elapsed_ms, - tts_enabled=request_source.tts_enabled, - device_id=request_source.device_id, - ) - return result - except Exception as chat_err: - logger.warning(f"[EXECUTE] Discussion fast path échoué : {chat_err}") - async with state.execution_lock: - state.execution_state["status"] = "success" - result = build_chat_mode_failure_response(session_id) - log_vocal_response( - session_id=session_id, - user_prompt=body.user_prompt, - source_type=request_source.type.value, - source_mode=request_source.mode.value, - routing_type="discussion_chat_failure", - agents_used=["discussion_chat"], - response_text=result["response"], - latency_ms=timer.elapsed_ms, - tts_enabled=request_source.tts_enabled, - device_id=request_source.device_id, - ) - return result + # NB : le mode Discussion (`mode=chat`) est traité en amont par + # `_handle_discussion_mode()` — avant le registre d'exécution. if routing_type == "casual_chat" and request_source.mode == ModeType.HA: logger.info("[EXECUTE] mode=ha bloque casual_chat → fast paths HA") @@ -506,8 +590,6 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): timeout=execute_timeout, ) agents_used = ["fast_path"] - async with state.execution_lock: - state.execution_state["status"] = "success" result = _build_fast_path_response(session_id, body.user_prompt, response_text) log_vocal_response( session_id=session_id, @@ -525,8 +607,6 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): except Exception as fast_err: logger.warning(f"[EXECUTE] Fast path échoué : {fast_err}") if should_block_full_pipeline(request_source): - async with state.execution_lock: - state.execution_state["status"] = "success" result = build_ha_mode_failure_response(session_id) log_vocal_response( session_id=session_id, @@ -555,8 +635,6 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): ) if ok: agents_used = ["ha_deterministic"] - async with state.execution_lock: - state.execution_state["status"] = "success" result = build_ha_fast_path_response( session_id, response_text, @@ -603,8 +681,6 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): ) if ok: agents_used = ["ha_fuzzy"] - async with state.execution_lock: - state.execution_state["status"] = "success" result = build_ha_fast_path_response( session_id, response_text, @@ -646,8 +722,6 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): ) if ok: agents_used = ["ha_llm_fallback"] - async with state.execution_lock: - state.execution_state["status"] = "success" result = build_ha_fast_path_response( session_id, response_text, @@ -680,8 +754,6 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): if conv_reply: logger.info(f"[EXECUTE] 💬 HA conversational → {conv_reply[:60]}") agents_used = ["ha_conversational"] - async with state.execution_lock: - state.execution_state["status"] = "success" result = build_ha_fast_path_response( session_id, conv_reply, @@ -705,8 +777,6 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): # Mode domotique : ne jamais lancer le pipeline complet if should_block_full_pipeline(request_source): logger.info("[EXECUTE] mode=ha — pipeline bloqué, réponse d'échec courte") - async with state.execution_lock: - state.execution_state["status"] = "success" result = build_ha_mode_failure_response(session_id) log_vocal_response( session_id=session_id, @@ -737,18 +807,33 @@ async def execute_chat(body: ExecuteRequestBody, _auth=Depends(optional_auth)): "tier": body.tier, "model": body.model } - pipeline_timeout = execute_timeout if request_source.mode == ModeType.CHAT else 120.0 + # `mode=chat` n'arrive jamais ici (traité par `_handle_discussion_mode` + # en amont) : ce chemin est celui des sources IHM/IDE/domotique. + pipeline_timeout = 120.0 async def _on_event(event_type: str, data: Any, engine=None): if engine: state.execution_state["engine_state"] = global_state_to_dict(engine.state) if event_type == "orchestration_completed": - state.execution_state["status"] = data.get("status", "success") + await state.set_execution_status(execution_key, data.get("status", "success")) try: await broadcast_event(event_type, data) except Exception: pass + # [#T344] Portillon d'exécution posé UNIQUEMENT ici, juste avant le + # pipeline complet : c'est le seul chemin restant qui touche l'Engine + # partagé. Les cinq chemins légers ont retourné avant — ils ne + # consomment aucun créneau du plafond (mais restent couverts par le + # compteur de requêtes en vol, contre-pression du dreamer). + # [#T324] Verrou d'exécution PAR SESSION : deux sessions différentes + # peuvent s'exécuter en parallèle ; une même session ne s'exécute + # jamais deux fois (409 doublon) ; le plafond global est respecté. + refus = await state.begin_execution(execution_key, body.user_prompt) + if refus is not None: + raise HTTPException(status_code=refus["status_code"], detail=refus["detail"]) + creneau_pris = True + result = await run_full_pipeline( user_prompt=body.user_prompt, session_id=session_id, @@ -759,9 +844,8 @@ async def _on_event(event_type: str, data: Any, engine=None): timeout_seconds=pipeline_timeout, ) - async with state.execution_lock: - state.execution_state["status"] = result.get("status", "success") - state.execution_state["engine_state"] = result.get("engine_state") + final_status = result.get("status", "success") + final_engine_state = result.get("engine_state") agents_used = result.get("agents_used") or [] response_text = result.get("response", "") @@ -779,12 +863,35 @@ async def _on_event(event_type: str, data: Any, engine=None): ) return result + except HTTPException: + # [#T344] Un 409 (doublon de session ou plafond) levé par le portillon + # juste avant le pipeline doit remonter tel quel, pas être avalé par le + # repli d'erreur générique. Le finally libère le compteur et, si + # nécessaire, l'éventuel créneau déjà pris. + raise except Exception as e: - async with state.execution_lock: - state.execution_state["status"] = "error" - state.execution_state["error_message"] = str(e) + final_status = "error" + final_error = str(e) logger.error(f"[EXECUTE] Erreur inattendue : {e}") return {"status": "error", "error": f"❌ {str(e)}"} + finally: + # [#T344] Libération GARANTIE sur tous les chemins : + # - le compteur de requêtes en vol est TOUJOURS décrémenté (contre- + # pression du dreamer), que le chemin ait pris un créneau ou non ; + # - l'entrée du registre n'est libérée QUE si `creneau_pris` (on ne + # libère jamais une entrée qu'on n'a pas créée — les chemins légers + # n'ont rien dans le registre). + await state.end_request() + if creneau_pris: + # [#T324] Libération systématique de l'entrée de session (try/finally): + # aucune fuite dans le registre, même sur exception. La vue agrégée + # reste "running" si une autre session tourne encore. + await state.end_execution( + execution_key, + status=final_status, + engine_state=final_engine_state, + error_message=final_error, + ) # ── Routes additionnelles (évite de patcher gui_server.py sur le Deck) ── @@ -818,10 +925,10 @@ async def vocal_abort(body: VocalAbortBody, _auth=Depends(optional_auth)): conversation_id=body.conversation_id, device_id=body.device_id, ) - async with state.execution_lock: - if state.execution_state.get("status") == "running": - state.execution_state["status"] = "success" - state.execution_state["error_message"] = "vocal_abort" + # [#T324] Force le déblocage : libère TOUTES les entrées du registre pour + # ne laisser aucune session bloquée pour toujours (garde-fou anti-fuite), + # puis pose la vue agrégée en "success". + await state.clear_executions(status="success", error_message="vocal_abort") return { "aborted": count, "active_before": list_active_vocal_streams(), diff --git a/api/routes/agents_crud.py b/api/routes/agents_crud.py index 732c239..998a0ae 100644 --- a/api/routes/agents_crud.py +++ b/api/routes/agents_crud.py @@ -367,9 +367,13 @@ def mutate(config: dict): entry["system_prompt"] = body.system_prompt.strip() or None if body.enabled is not None: entry["enabled"] = body.enabled - # [#T233] Permissions d'outils : null = « tous les outils » (la - # distinction [] / null est conservée telle quelle en config). - if body.allowed_tools is not None: + # [#T233/#T345] Permissions d'outils : pour un PATCH, `None` a deux + # sens qu'il faut distinguer — « champ absent » (on ne touche pas) + # et « fourni à null » (= tous les outils). `model_fields_set` + # (Pydantic 2) nomme les champs réellement présents dans le corps + # JSON : c'est ce qui sépare les deux cas. Absent → inchangé ; + # fourni à null → on écrit null ; fourni à [] → on écrit []. + if "allowed_tools" in body.model_fields_set: entry["allowed_tools"] = body.allowed_tools updated.update(entry) return diff --git a/api/routes/context.py b/api/routes/context.py index 4ee36b4..c9f7529 100644 --- a/api/routes/context.py +++ b/api/routes/context.py @@ -350,7 +350,10 @@ def bulk_set_models_status(body: BulkStatusBody): if model is None: introuvables.append(model_id) continue - if model.get("status") == body.status: + + # [#T380] Même si le statut est identique, on appelle set_model_status + # pour forcer la libération de la revendication de la sonde (desactive_par_sonde = 0). + if model.get("status") == body.status and not model.get("desactive_par_sonde"): inchanges.append(model_id) continue if set_model_status(model_id, body.status): @@ -462,6 +465,13 @@ def get_models_health(): Une sonde dont personne ne voit les résultats reproduirait le défaut qu'elle corrige — les 6 modèles disjonctés du 12/08 n'étaient visibles qu'en lisant le journal du serveur. + + Le bloc `catalogue` rend visible SANS ouvrir SQLite la zone grise mesurée + le 17/08 (52 modèles actifs sans routing_tier, sondés chaque heure, jamais + choisis par le routeur) : profondeur réelle de chaque tier, modèles sondés + non routables, et verdict de la règle de justification par spécialité + (cf. core.models_db.audit_zone_grise). Forme additive : les clés existantes + (sonde, modeles, muets, eteints_par_la_sonde) restent inchangées. """ from core.model_health_probe import intervalle_sonde, peut_desactiver, sonde_activee from core.runtime_db import get_connection @@ -482,6 +492,30 @@ def get_models_health(): } for ligne in lignes ] + + # ── Zone grise : sondés (actifs, testés chaque cycle) mais non routables ── + # Un audit en échec ne doit pas priver l'endpoint de santé de sa forme + # historique : le bloc `catalogue` se dégrade en erreur explicite. + try: + from core.models_db import audit_zone_grise, compter_profondeur_tiers + + audit = audit_zone_grise() + actifs_hors_tier = set(audit["hors_tier"]) + sondes_non_routables = [ + m["model_id"] for m in modeles if m["model_id"] in actifs_hors_tier + ] + catalogue = { + "profondeur_tiers": compter_profondeur_tiers(), + "sondes_non_routables": sondes_non_routables, + "nb_sondes_non_routables": len(sondes_non_routables), + "zone_grise": audit["zone_grise"], + "justifies_hors_routage": audit["justifies"], + "conforme": audit["conforme"], + } + except Exception as e: + logger.warning(f"[MODELS-HEALTH] Audit de la zone grise indisponible : {e}") + catalogue = {"erreur": str(e)} + return { "sonde": { "activee": sonde_activee(), @@ -491,6 +525,7 @@ def get_models_health(): "modeles": modeles, "muets": [m["model_id"] for m in modeles if m["echecs_consecutifs"] > 0], "eteints_par_la_sonde": [m["model_id"] for m in modeles if m["desactive_par_sonde"]], + "catalogue": catalogue, } diff --git a/api/routes/execution.py b/api/routes/execution.py index 66ff6bc..28c8ab6 100644 --- a/api/routes/execution.py +++ b/api/routes/execution.py @@ -41,8 +41,12 @@ async def stop_execution(): if state.execution_state.get("status") != "running": raise HTTPException(status_code=400, detail="Aucune exécution en cours à arrêter.") _stop_requested = True - state.execution_state["status"] = "error" - state.execution_state["error_message"] = "Arrêt demandé par l'utilisateur" + + # [#T324] Libère TOUTES les entrées du registre (aucune session ne reste + # bloquée) puis pose la vue agrégée en "error". La vue agrégée étant + # "running" dès qu'une exécution tourne, le garde-fou ci-dessus est exact + # même avec plusieurs sessions en parallèle. + await state.clear_executions(status="error", error_message="Arrêt demandé par l'utilisateur") await broadcast_event("orchestration_completed", { "status": "error", @@ -110,8 +114,8 @@ async def websocket_endpoint(websocket: WebSocket, token: str = Depends(require_ state = get_app_state() async with state.execution_lock: if state.execution_state.get("status") == "running": - state.execution_state["status"] = "error" - state.execution_state["error_message"] = "Arrêt WebSocket" + # [#T324] Libère toutes les entrées (aucune session bloquée). + await state.clear_executions(status="error", error_message="Arrêt WebSocket") await websocket.send_json({"type": "ack", "action": "stop"}) elif action == "get_status": from core.app_state import get_app_state diff --git a/api/routes/health.py b/api/routes/health.py index 3e3436b..349d642 100644 --- a/api/routes/health.py +++ b/api/routes/health.py @@ -20,11 +20,26 @@ @router.get("/healthz") async def healthz() -> dict: - """Liveness légère et rapide (sans auth). 200 = moteur vivant.""" + """Liveness légère et rapide (sans auth). 200 = moteur vivant. + + Sur cette sonde PUBLIQUE, on n'ajoute qu'au maximum le SHA court du commit + (champ `commit`, absent si indéterminable) — jamais de chemin de fichier, de + branche ni de nom de machine. L'identité complète vit sur la route authentifiée + /api/observability/version. Le SHA est lu depuis le cache de core.version_info : + aucun `git` supplémentaire par requête. + """ status = None try: from core.app_state import get_app_state status = get_app_state().execution_state.get("status") except Exception as exc: # pragma: no cover - défensif, ne doit jamais 500 logger.debug("[HEALTHZ] app_state indisponible : %s", exc) - return {"ok": True, "service": "tab5-engine", "status": status} + payload = {"ok": True, "service": "tab5-engine", "status": status} + try: + from core.version_info import get_short_commit + short = get_short_commit() + if short: + payload["commit"] = short + except Exception as exc: # pragma: no cover - défensif, ne doit jamais 500 + logger.debug("[HEALTHZ] version indisponible : %s", exc) + return payload diff --git a/api/routes/observability.py b/api/routes/observability.py index e580a56..ee7ce6f 100644 --- a/api/routes/observability.py +++ b/api/routes/observability.py @@ -46,6 +46,19 @@ def _snapshot_breakers() -> list: return breakers +@router.get("/version") +async def version_info() -> dict: + """Identité du code en cours d'exécution (SHA court + date du commit). + + Route AUTHENTIFIÉE (le module est monté avec `_AUTH_DEP` dans gui_server.py) : + contrairement à la sonde publique /healthz, elle expose l'identité complète du + commit. Résolution effectuée une seule fois puis mise en cache + (core.version_info) : jamais de `git` par requête. + """ + from core.version_info import get_version_info + return get_version_info() + + @router.get("/circuit-breakers") async def circuit_breakers_status() -> dict: """État JSON de tous les circuit breakers + résumé agrégé.""" diff --git a/api/routes/openai_proxy.py b/api/routes/openai_proxy.py index f75c243..f19fdbe 100644 --- a/api/routes/openai_proxy.py +++ b/api/routes/openai_proxy.py @@ -27,6 +27,12 @@ restent sur le chemin bufferisé : le flux ne portant que du texte, un appel d'outil tronqué en morceaux serait ininterprétable par le client. +`usage` (#T357) : le champ ne porte QUE des comptages de tokens réels remontés +par le provider amont ; quand il n'y en a pas, il est entièrement omis (il est +facultatif côté OpenAI). Il n'est jamais estimé — auparavant le chemin +non-streamé y publiait un comptage de MOTS, sur lequel les clients calaient leur +fenêtre de contexte et leur budget. + Limites connues (un agent de code comme OpenCode/Cline s'y heurtera) : - `/v1/models` n'expose pas les options par modèle (reasoning effort, thinking mode, température imposée). @@ -345,14 +351,109 @@ def _extraire_reponse(brut: Any) -> tuple[str, list | None]: return ("" if brut is None else str(brut)), None +def _entier_de_comptage(valeur: Any) -> int | None: + """Retourne `valeur` si c'est un comptage de tokens exploitable, sinon None.""" + # `bool` est un `int` en Python : un True s'inviterait comme « 1 token ». + if isinstance(valeur, bool) or not isinstance(valeur, int): + return None + return valeur + + +# Schémas d'usage non-OpenAI qui circulent dans le moteur, et le nom OpenAI de +# chaque compteur. RENOMMAGE STRICT : aucune valeur n'est dérivée ni calculée. +_SCHEMAS_USAGE_ETRANGERS = ( + # Gemini natif — `usageMetadata`, réellement remonté par + # gemini_native.generate_stream() sur son chunk final. + { + "prompt_tokens": "promptTokenCount", + "completion_tokens": "candidatesTokenCount", + "total_tokens": "totalTokenCount", + }, + # Anthropic natif. + { + "prompt_tokens": "input_tokens", + "completion_tokens": "output_tokens", + }, +) + + +def _normaliser_usage(usage_brut: Any) -> dict | None: + """ + Rend un `usage` exploitable par un client OpenAI, ou None s'il n'y en a pas. + + [#T357] Règle centrale : **on ne fabrique jamais de chiffre**. Soit le + provider amont a renvoyé un comptage réel de tokens, soit le champ `usage` + est entièrement omis de la réponse — il est facultatif dans l'API OpenAI, et + un chiffre faux est pire qu'un chiffre absent puisque le client pilote sa + fenêtre de contexte et son budget avec. Le comptage en MOTS qui remplissait + ce champ annonçait plusieurs fois moins que la réalité (prompt médian mesuré + le 17/08 sur 630 appels : 67 523 tokens réels). + + Trois schémas existent côté providers : + - OpenAI (`prompt_tokens`…) : transmis **tel quel**, extras compris + (`prompt_cache_hit_tokens`, `prompt_tokens_details`…) ; + - Gemini natif (`usageMetadata`) et Anthropic natif (`input_tokens`…) : + les compteurs sont RENOMMÉS vers les noms OpenAI. `total_tokens` n'est + posé que si le provider l'a fourni — il n'est pas recalculé. + """ + if not isinstance(usage_brut, dict): + return None + + # Déjà au format OpenAI : rien à traduire, on ne touche à rien. + if (_entier_de_comptage(usage_brut.get("prompt_tokens")) is not None + or _entier_de_comptage(usage_brut.get("completion_tokens")) is not None): + return usage_brut + + for schema in _SCHEMAS_USAGE_ETRANGERS: + traduit = {} + for cle_openai, cle_source in schema.items(): + valeur = _entier_de_comptage(usage_brut.get(cle_source)) + if valeur is not None: + traduit[cle_openai] = valeur + # Un `total_tokens` seul ne dit pas au client ce qu'il a consommé en + # entrée : on exige au moins un des deux compteurs principaux. + if "prompt_tokens" in traduit or "completion_tokens" in traduit: + return traduit + + return None + + +def _extraire_usage_reel(brut: Any, usage_sink: dict | None = None) -> dict | None: + """ + Récupère l'usage réel porté par la réponse d'un provider (chemin bufferisé). + + `generate()` retourne aujourd'hui soit une chaîne, soit le `message` OpenAI + quand le modèle appelle un outil. Dans le cas chaîne, l'usage réel de la + réponse HTTP est enregistré en base par `_record_usage()` côté provider mais + ne peut pas être porté par le type de retour : les providers + OpenAI-compatibles le déposent alors dans le dict `_usage_sink` fourni par + l'appelant (canal latéral propre à la requête — jamais d'état partagé sur + l'instance). `brut` reste prioritaire (cas tool_calls, où le dict porte + déjà l'usage) ; à défaut on lit le sink. Aucun des deux ne porte rien → + None, et le champ sera omis — surtout pas une estimation de repli. + """ + if isinstance(brut, dict): + usage = _normaliser_usage(brut.get("usage")) + if usage is not None: + return usage + if isinstance(usage_sink, dict): + return _normaliser_usage(usage_sink.get("usage")) + return None + + def _make_completion_response(model: str, content: str, finish_reason: str = "stop", - input_tokens: int = 0, output_tokens: int = 0, - tool_calls: list | None = None) -> dict: - """Construit une réponse OpenAI-compatible (non-streaming).""" + tool_calls: list | None = None, + usage: dict | None = None) -> dict: + """ + Construit une réponse OpenAI-compatible (non-streaming). + + [#T357] `usage` n'est posé que si le provider amont a fourni un comptage + réel (cf. `_normaliser_usage`) ; sinon la clé est absente de la réponse. + """ message: dict[str, Any] = {"role": "assistant", "content": content or None} if tool_calls: message["tool_calls"] = tool_calls - return { + reponse: dict[str, Any] = { "id": f"chatcmpl-{uuid.uuid4().hex[:12]}", "object": "chat.completion", "created": int(time.time()), @@ -362,12 +463,10 @@ def _make_completion_response(model: str, content: str, finish_reason: str = "st "message": message, "finish_reason": finish_reason, }], - "usage": { - "prompt_tokens": input_tokens, - "completion_tokens": output_tokens, - "total_tokens": input_tokens + output_tokens, - }, } + if usage: + reponse["usage"] = usage + return reponse def _make_stream_chunk(model: str, delta_content: str, finish_reason: str | None = None, @@ -392,8 +491,9 @@ def _make_stream_chunk(model: str, delta_content: str, finish_reason: str | None }], } if usage: - # Usage réel propagé quand le provider le remonte (stream_options - # include_usage) ; absent sinon, comme avant. + # [#T357] Usage réel uniquement (déjà passé par `_normaliser_usage`) : + # aucun chunk ne porte de comptage inventé, le champ est simplement + # absent quand le provider n'a rien remonté. chunk["usage"] = usage return f"data: {json.dumps(chunk, ensure_ascii=False)}\n\n" @@ -414,36 +514,51 @@ async def _streamer_texte(provider, model_name: str, system_prompt: str, transcr transformée en réponse d'erreur HTTP (les en-têtes sont déjà partis) : on termine le flux proprement (chunk final + [DONE]) et on journalise — l'erreur n'est pas avalée en silence. + + [#T343-C] L'étiquette d'agent est posée ICI, pas sur la route : la route + rend `StreamingResponse` immédiatement et c'est Starlette qui consomme le + générateur APRÈS, hors du bloc `with`. Posée autour de l'itération, elle + atteint le thread du pool via le pont `iterate_in_threadpool` + (anyio.to_thread.run_sync copie le contexte, vérifié à la mesure) — + c'est là que `record_usage` écrit la ligne de dépense. """ usage_reel = None tokens_emis = 0 - try: - async for item in iterate_in_threadpool(provider.generate_stream( - system_prompt, transcript, - messages=messages_provider, - temperature=temperature, - max_tokens=max_tokens, - session_id=session_id, - )): - token = (item or {}).get("token") or "" - if token: - tokens_emis += 1 - yield _make_stream_chunk(model_name, token) - if item.get("usage"): - usage_reel = item["usage"] - if item.get("done"): - break - except Exception as e: - # En-têtes HTTP déjà partis : impossible de répondre en erreur HTTP. On - # ferme le flux proprement pour que le client ne reste pas en attente - # d'un chunk qui ne viendra jamais. - logger.error( - f"[OPENAI_PROXY] Streaming interrompu après {tokens_emis} token(s) " - f"émis : {e}" - ) - yield _make_stream_chunk(model_name, "", finish_reason="stop", usage=usage_reel) - yield "data: [DONE]\n\n" - return + from core.agent_trace import agent_courant + with agent_courant("proxy_v1"): + try: + async for item in iterate_in_threadpool(provider.generate_stream( + system_prompt, transcript, + messages=messages_provider, + temperature=temperature, + max_tokens=max_tokens, + session_id=session_id, + )): + token = (item or {}).get("token") or "" + if token: + tokens_emis += 1 + yield _make_stream_chunk(model_name, token) + # [#T357] Le contrat `generate_stream` est {"token", "done", + # "usage"} mais tous les providers ne parlent pas le schéma + # OpenAI : gemini_native remonte un `usageMetadata` + # (promptTokenCount…), qui transmis tel quel donnerait au client + # un `usage` dont aucune clé attendue n'existe. + usage_candidat = _normaliser_usage(item.get("usage")) + if usage_candidat: + usage_reel = usage_candidat + if item.get("done"): + break + except Exception as e: + # En-têtes HTTP déjà partis : impossible de répondre en erreur HTTP. On + # ferme le flux proprement pour que le client ne reste pas en attente + # d'un chunk qui ne viendra jamais. + logger.error( + f"[OPENAI_PROXY] Streaming interrompu après {tokens_emis} token(s) " + f"émis : {e}" + ) + yield _make_stream_chunk(model_name, "", finish_reason="stop", usage=usage_reel) + yield "data: [DONE]\n\n" + return yield _make_stream_chunk(model_name, "", finish_reason="stop", usage=usage_reel) yield "data: [DONE]\n\n" @@ -545,6 +660,13 @@ async def chat_completions(request: ChatCompletionRequest): # client l'envoie) et étiquette d'agent (Cline/Continue consomment hors # de tout agent — même motif que coding_front #T308). from core.agent_trace import agent_courant + # [#T357] Canal latéral d'usage réel : un dict propre à la requête, + # déposé dans les kwargs sous `_usage_sink`. Les providers + # OpenAI-compatibles y écrivent l'usage de la réponse HTTP quand + # generate() rend une simple chaîne (le type de retour ne peut pas le + # porter). Les providers qui ne le connaissent pas l'ignorent — + # l'usage reste alors omis, jamais estimé. + usage_sink: dict[str, Any] = {} with agent_courant("proxy_v1"): brut = await asyncio.to_thread( provider.generate, @@ -554,30 +676,37 @@ async def chat_completions(request: ChatCompletionRequest): temperature=request.temperature, max_tokens=request.max_tokens, session_id=request.session_id, + _usage_sink=usage_sink, **options_outils, ) response_text, tool_calls = _extraire_reponse(brut) + # [#T357] Usage réel s'il est porté par la réponse du provider ou par + # le sink, sinon None : le champ sera omis, jamais estimé. + usage_reel = _extraire_usage_reel(brut, usage_sink) finish_reason = "tool_calls" if tool_calls else "stop" if request.stream: # Outils en streaming : la réponse complète (appel d'outil compris) - # part en un seul chunk — le flux ne porte que du texte. + # part en un seul chunk — le flux ne porte que du texte. C'est le cas + # normal d'un client agentique : le chunk final doit donc porter + # l'usage réel quand il existe (#T357). async def _stream_gen(): yield _make_stream_chunk(model_name, response_text, tool_calls=tool_calls) - yield _make_stream_chunk(model_name, "", finish_reason=finish_reason) + yield _make_stream_chunk( + model_name, "", finish_reason=finish_reason, usage=usage_reel, + ) yield "data: [DONE]\n\n" return StreamingResponse(_stream_gen(), media_type="text/event-stream") else: - # Comptage approximatif (mots), inchangé — le comptage réel de tokens - # reste porté par les providers via core.token_tracker. - tokens_in = max(1, len((system_prompt + "\n" + transcript).split())) - tokens_out = max(1, len(response_text.split())) + # [#T357] Plus de comptage de MOTS présenté comme des tokens : ce + # champ pilotait la fenêtre de contexte et le budget du client avec + # un chiffre plusieurs fois trop bas. Sans usage amont, pas de clé. return JSONResponse(_make_completion_response( model_name, response_text, finish_reason=finish_reason, - input_tokens=tokens_in, output_tokens=tokens_out, tool_calls=tool_calls, + usage=usage_reel, )) except ModeleInconnuError as e: diff --git a/api/routes/streaming.py b/api/routes/streaming.py index 1bf5104..5e7e2ff 100644 --- a/api/routes/streaming.py +++ b/api/routes/streaming.py @@ -17,9 +17,10 @@ from pydantic import BaseModel from api.routes.agents import ExecuteRequestBody -from core.app_state import get_app_state +from core.app_state import _cle_execution, get_app_state from core.auth import optional_auth from core.serializers import global_state_to_dict +from core.source_router import parse_source logger = logging.getLogger(__name__) @@ -81,6 +82,7 @@ async def _execute_stream_generator( session_id: str, tier: str | None = None, model: str | None = None, + execution_key: str | None = None, ): """ Générateur SSE pour /api/execute/stream. @@ -149,8 +151,8 @@ async def _execute_stream_generator( final_response = evt.get("response", "") except _json.JSONDecodeError: pass - async with state.execution_lock: - state.execution_state["status"] = "success" + if execution_key: + await state.set_execution_status(execution_key, "success") if conv_id and final_response: from core.vocal_session import record_vocal_turn record_vocal_turn( @@ -203,8 +205,8 @@ async def _execute_stream_generator( system_prompt_suffix=suffix, inject_project_context=True, ) - async with state.execution_lock: - state.execution_state["status"] = "success" + if execution_key: + await state.set_execution_status(execution_key, "success") event = _json.dumps({"type": "done", "response": response, "agents_used": ["fast_path"]}) yield f"data: {event}\n\n" return @@ -214,8 +216,8 @@ async def _on_event(event_type: str, data: Any, engine=None): """Callback du pipeline → inject dans la queue SSE.""" if engine: state.execution_state["engine_state"] = global_state_to_dict(engine.state) - if event_type == "orchestration_completed": - state.execution_state["status"] = data.get("status", "success") + if event_type == "orchestration_completed" and execution_key: + await state.set_execution_status(execution_key, data.get("status", "success")) await queue.put({"type": event_type, "data": data}) # [#T194] Override par requête + source_router tier recommandé. @@ -266,9 +268,8 @@ async def _run_pipeline(): if item["type"] == "__done__": result = item.get("result", {}) - async with state.execution_lock: - state.execution_state["status"] = result.get("status", "success") - state.execution_state["engine_state"] = result.get("engine_state") + if execution_key: + await state.set_execution_status(execution_key, result.get("status", "success")) done_event = _json.dumps({ "type": "done", "response": result.get("response", ""), @@ -279,9 +280,8 @@ async def _run_pipeline(): return elif item["type"] == "__error__": - async with state.execution_lock: - state.execution_state["status"] = "error" - state.execution_state["error_message"] = item.get("error", "Erreur inconnue") + if execution_key: + await state.set_execution_status(execution_key, "error") err_event = _json.dumps({"type": "error", "message": item.get("error", "Erreur inconnue")}) yield f"data: {err_event}\n\n" return @@ -297,16 +297,12 @@ async def _run_pipeline(): logger.info(f"[STREAM] Client déconnecté ({session_id}) → annulation pipeline") if pipeline_task and not pipeline_task.done(): pipeline_task.cancel() - async with state.execution_lock: - if state.execution_state.get("status") == "running": - state.execution_state["status"] = "error" - state.execution_state["error_message"] = "Client déconnecté" + if execution_key: + await state.set_execution_status(execution_key, "error") except Exception as e: logger.error(f"[STREAM] Erreur générateur SSE : {e}") - async with state.execution_lock: - if state.execution_state.get("status") == "running": - state.execution_state["status"] = "error" - state.execution_state["error_message"] = str(e) + if execution_key: + await state.set_execution_status(execution_key, "error") try: import json as _j yield f"data: {_j.dumps({'type': 'error', 'message': str(e)})}\n\n" @@ -320,18 +316,19 @@ async def _execute_stream_with_cleanup( session_id: str, tier: str | None = None, model: str | None = None, + execution_key: str | None = None, ): - """Enveloppe le générateur SSE pour libérer execution_state même si client coupe.""" + """Enveloppe le générateur SSE pour libérer l'entrée de session même si le + client coupe (garde-fou anti-fuite #T324).""" state = get_app_state() try: async for chunk in _execute_stream_generator( - user_prompt, source, session_id, tier=tier, model=model + user_prompt, source, session_id, tier=tier, model=model, execution_key=execution_key ): yield chunk finally: - async with state.execution_lock: - if state.execution_state.get("status") == "running": - state.execution_state["status"] = "success" + if execution_key: + await state.end_execution(execution_key) @router.post("/api/execute/stream") @@ -340,22 +337,21 @@ async def execute_chat_stream(body: ExecuteRequestBody, _auth=Depends(optional_a Streaming SSE du pipeline conversationnel pour l'IHM web. """ state = get_app_state() + request_source = parse_source(body.source) - async with state.execution_lock: - if state.execution_state.get("status") == "running": - raise HTTPException( - status_code=409, - detail="Une exécution est déjà en cours. Attendez sa fin ou utilisez /api/stop." - ) - state.execution_state.update({ - "status": "running", "objective": body.user_prompt, - "engine_state": None, "error_message": None, - }) + # [#T324] Verrou d'exécution PAR SESSION : deux sessions différentes + # peuvent streamer en parallèle ; une même session ne s'exécute jamais + # deux fois (409 doublon) ; plafond global de concurrence respecté. + execution_key = _cle_execution(body.session_id, request_source) + refus = await state.begin_execution(execution_key, body.user_prompt) + if refus is not None: + raise HTTPException(status_code=refus["status_code"], detail=refus["detail"]) session_id = f"stream_{int(asyncio.get_event_loop().time())}" return StreamingResponse( _execute_stream_with_cleanup( - body.user_prompt, body.source, session_id, tier=body.tier, model=body.model + body.user_prompt, body.source, session_id, + tier=body.tier, model=body.model, execution_key=execution_key, ), media_type="text/event-stream", headers={ diff --git a/claude_desktop_config.json.example b/claude_desktop_config.json.example index b743665..cdc6849 100644 --- a/claude_desktop_config.json.example +++ b/claude_desktop_config.json.example @@ -8,7 +8,7 @@ ], "env": { "GITHUB_TOKEN": "ton_token_ici", - "ANTIGRAVITY_HOME": "e:\\AuxFilsDesIdees\\moteur_agents" + "ANTIGRAVITY_HOME": "/path/to/vromvrom-engine" }, "disabled": false, "autoApprove": [] diff --git a/config.example.json b/config.example.json index 6163c77..67cc426 100644 --- a/config.example.json +++ b/config.example.json @@ -1,51 +1,159 @@ { "_commentaire": "Copier ce fichier en config.json et ajuster selon vos besoins.", - "_doc": "Les tiers abstraits (leger/moyen/fort) permettent de changer de modèle sans toucher au code.", - - "planner_model": "moyen", - "executor_model": "leger", + "_doc": "Les tiers abstraits (leger/moyen/fort) et le fast-path vocal reproduisent la cascade utilisée en prod. Les slugs sont des exemples : un provider absent (pas de clé) est simplement sauté.", + "planner_model": "gemini-3.5-flash-free", + "executor_model": "gemini-3.5-flash-free", "reviewer_model": "leger", - + "ha_model": "moyen", "tiers": { - "leger": ["local", "gemini-flash", "github-gpt4o-mini"], - "moyen": ["gemini-flash", "deepseek-chat", "github-gpt4o"], - "fort": ["gemini-pro", "deepseek-reasoner", "github-llama-70b"], - "automatique": ["local", "gemini-flash", "deepseek-chat", "gemini-pro"] + "leger": [ + "gemini-3.1-flash-lite-free", + "gemini-3.5-flash-free", + "deepseek-coder-v2-lite", + "gemma-4-31b", + "qwen3-coder-next", + "gemini-3.5-flash-high-cli", + "gemini-3.5-flash-medium-cli", + "deepseek-chat", + "MiniMax-M2.1-highspeed", + "MiniMax-M2" + ], + "moyen": [ + "gemini-3.5-flash-high-cli", + "gemini-3.5-flash-free", + "openrouter/auto", + "deepseek-v4-flash", + "gpt-oss-120b", + "claude-sonnet-4.6-thinking-cli", + "gemini-3.5-flash-medium-cli", + "deepseek-reasoner", + "gemini-3.5-flash-paid", + "codestral-latest", + "mistral-large-latest", + "MiniMax-M2.5-highspeed", + "MiniMax-M2.7" + ], + "fort": [ + "deepseek-reasoner", + "claude-sonnet-4-6", + "claude-opus-4-7", + "claude-opus-4-8", + "deepseek-v4-pro", + "gemini-3.1-pro-preview-paid", + "claude-opus-4.6-thinking-cli", + "gemini-3.5-flash-high-cli", + "gpt-oss-120b", + "gemini-3-pro-preview-paid", + "gemini-3.1-pro-customtools-paid", + "MiniMax-M3", + "MiniMax-M2.7" + ], + "automatique": [ + "gemini-3.5-flash-high-cli", + "deepseek-reasoner", + "MiniMax-M3", + "openrouter/auto", + "claude-sonnet-4.6-thinking-cli", + "deepseek-chat", + "gemini-3.5-flash-free", + "deepseek-v4-pro" + ] }, - "timeout_global_s": 120, "max_retries": 3, "max_dag_stages": 10, - "fast_path_enabled": true, "fast_path_cache_ttl_s": 15, - "semantic_cache": { "enabled": false, - "similarity_threshold": 0.92 + "similarity_threshold": 0.95 + }, + "budget_guard": { + "gemini_free_tokens_per_hour": 1000000, + "deepseek_free_requests_per_day": 200, + "daily_budget_usd": 0.5, + "auditor_weekly_budget_usd": 1.0, + "total_daily_budget_usd": 1.0 + }, + "cascade_quality_escalation": { + "enabled": true, + "quality_threshold": 6.0, + "eligible_domains": [ + "code_generation", + "analysis", + "sysadmin" + ], + "escalated_tier": "fort" + }, + "routing_policy": { + "_documentation": "Hors routage auto. Liste d'exclusions : modèles à clés mortes ou slugs retirés. Adapter à votre catalogue.", + "excluded_models": [ + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8-direct", + "claude-haiku-4-5-direct", + "meta-llama/llama-3.3-70b-instruct:free", + "meta-llama/llama-3.2-3b-instruct:free", + "dashscope", + "dashscope/qwen3.7-plus", + "dashscope/qwen3.6-plus", + "dashscope/qwen3.5-plus", + "dashscope/qwen3-max-2026-01-23", + "dashscope/qwen3-coder-next", + "dashscope/qwen3-coder-plus", + "dashscope/kimi-k2.5", + "dashscope/glm-5", + "dashscope/glm-4.7", + "dashscope/MiniMax-M2.5" + ] + }, + "persistent_agents": { + "daemon_model": "leger", + "daemon_interval_minutes": 10, + "daemon_enabled": true, + "dreamer_model": "leger", + "dreamer_schedule": "02:00", + "dreamer_enabled": true, + "dreamer_idle_trigger_hours": 3, + "routines_model": "fort", + "dreamcoder_max_cycle_minutes": 30, + "auditor_enabled": true, + "auditor_scopes": [ + "core", + "agents", + "api", + "memory", + "tools" + ], + "auditor_trigger_mode": "task_count", + "auditor_trigger_task_count": 10, + "auditor_schedule_weekday": 6, + "auditor_schedule_time": "03:00", + "auditor_model_tier": "moyen", + "auditor_escalation_tier": "fort", + "auditor_confidence_threshold": 6.0, + "auditor_dedup_similarity_threshold": 0.75, + "auditor_max_corpus_chars": 150000, + "dreamcoder_repo_path": "" }, - "swarm": { "enabled": false, "workers": [] }, - "hitl": { "enabled": false, "timeout_s": 300 }, - "visual_qa": { "enabled": false, "puppeteer_url": "http://localhost:3000" }, - "local_llm": { "lmstudio_url": "http://localhost:1234", "ollama_url": "http://localhost:11434", + "llamacpp_url": "http://127.0.0.1:8080", "model": "qwen2.5-14b-instruct" }, - "context_compression_threshold": 1500, "max_tool_output_chars": 4000 } diff --git a/core/acceptance_contract.py b/core/acceptance_contract.py index 36f2140..eb59f3a 100644 --- a/core/acceptance_contract.py +++ b/core/acceptance_contract.py @@ -45,8 +45,29 @@ # Clé sous laquelle le Planner transporte son contrat dans les metadata du StateUpdate. CLE_CONTRAT = "contrat_acceptation" +# [Incident du 17/08] Clé de l'état initial constaté des critères AU MOMENT où +# le plan est posé. Un critère déjà vrai AVANT que le lot ne commence n'est pas +# une preuve de travail : « vérifier qu'un fichier préexistant est accessible » +# est satisfait quoi que fasse le lot. Le porteur du constat peut être absent +# (plans antérieurs à ce mécanisme) — par défaut, un critère reste alors +# considéré comme une preuve possible : le doute ne doit jamais rendre le +# jugement plus permissif, seulement plus strict. +CLE_ETAT_INITIAL = "etat_initial_criteres" + +# [Incident du 17/08] Clé des substitutions DÉCLARÉES par un plan correctif +# (un critère du plancher devenu impossible y est remplacé, de façon tracée, +# par un autre). Jamais de remplacement silencieux : toute substitution passe +# par une déclaration explicite, journalisée à l'évaluation. +CLE_SUBSTITUTIONS = "substitutions_acceptation" + TYPES_CONNUS = ("commande", "fichier_contient", "fichier_existe") +# Types de critères dont on peut constater l'état initial à moindre coût : +# les critères fichier. Les critères `commande` sont exclus du constat : une +# commande de vérification peut être longue (suite de tests) et la lancer une +# fois de plus avant chaque lot n'est pas acceptable. +TYPES_CONSTATABLES = ("fichier_contient", "fichier_existe") + # Délai maximum d'une commande de vérification (une suite de tests peut être longue). TIMEOUT_COMMANDE_S = 180 @@ -78,12 +99,19 @@ @dataclass(frozen=True) class Critere: - """Un critère d'acceptation, normalisé.""" + """Un critère d'acceptation, normalisé. + + `satisfait_d_avance` est posé à l'ÉVALUATION (jamais par le Planner) quand + le constat d'état initial montre que le critère était déjà vrai avant le + début du travail : il reste évalué et visible dans le rapport, mais il ne + compte ni comme preuve ni comme verdict. + """ type: str valeur: str attendu: str | None = None description: str = "" + satisfait_d_avance: bool = False def libelle(self) -> str: if self.description: @@ -110,9 +138,34 @@ class RapportContrat: resultats: list[ResultatCritere] = field(default_factory=list) + @property + def resultats_satisfaits_d_avance(self) -> list[ResultatCritere]: + """Résultats dont le critère était déjà vrai avant le début du lot.""" + return [r for r in self.resultats if r.critere.satisfait_d_avance] + + @property + def echecs_satisfaits_d_avance(self) -> list[ResultatCritere]: + """ + Échecs parmi les critères satisfaits d'avance. Ils ne servent PAS de + preuve (comme tout critère satisfait d'avance), mais ils BLOQUENT le + verdict : un critère vrai avant le lot qui échoue maintenant est une + régression, et elle doit rester visible dans le rapport. + """ + return [r for r in self.echecs if r.critere.satisfait_d_avance] + @property def verifiables(self) -> list[ResultatCritere]: - return [r for r in self.resultats if r.satisfait is not None] + """ + Résultats tranchés, HORS critères satisfaits d'avance : ceux-là sont + vrais avant même que le lot ne commence et ne prouvent donc rien sur + le travail accompli. Incident du 17/08 : un contrat correctif composé + uniquement de critères satisfaits d'avance (des fichiers préexistants) + avait absous un DAG en erreur. + """ + return [ + r for r in self.resultats + if r.satisfait is not None and not r.critere.satisfait_d_avance + ] @property def echecs(self) -> list[ResultatCritere]: @@ -125,14 +178,27 @@ def non_verifiables(self) -> list[ResultatCritere]: @property def determinable(self) -> bool: """ - Vrai si le contrat peut trancher. Un contrat vide, ou dont AUCUN critère - n'est vérifiable, ne doit pas décider à la place de la revue LLM : dans - ce cas l'appelant garde son comportement d'avant. + Vrai si le contrat peut trancher. Un contrat vide, dont AUCUN critère + n'est vérifiable, ou dont les seuls critères vérifiables étaient + satisfaits d'avance, ne doit pas décider à la place de la revue LLM : + dans ces cas l'appelant garde son comportement d'avant. Conséquences + directes : un contrat entièrement satisfait d'avance ne peut plus + absoudre un DAG en erreur (rien n'y prouve le travail du lot) ; et un + critère satisfait d'avance qui ÉCHOUE maintenant reste un échec — + c'est une régression, et le doute doit toujours aller vers le strict. """ return bool(self.verifiables) @property def satisfait(self) -> bool: + """ + Le contrat est satisfait si au moins un critère PROUVE le travail + (vérifiable et non satisfait d'avance) et qu'AUCUN critère n'échoue — + y compris parmi les critères satisfaits d'avance : un critère vrai + avant le lot qui échoue maintenant est une régression, pas une + anecdote. Un critère satisfait d'avance ne peut donc JAMAIS servir de + preuve, mais son échec peut toujours faire tomber le verdict. + """ return self.determinable and not self.echecs def resume(self) -> str: @@ -140,11 +206,15 @@ def resume(self) -> str: lignes = [] for r in self.resultats: marque = {True: "✅", False: "❌", None: "⚠️"}[r.satisfait] + if r.critere.satisfait_d_avance: + marque += " (satisfait d'avance — exclu du verdict)" lignes.append(f"{marque} {r.critere.libelle()} — {r.detail}") entete = ( f"CONTRAT D'ACCEPTATION : {len(self.echecs)} critère(s) en échec sur " f"{len(self.verifiables)} vérifiable(s)" ) + if self.resultats_satisfaits_d_avance: + entete += f" ({len(self.resultats_satisfaits_d_avance)} satisfait(s) d'avance, non comptés)" if self.non_verifiables: entete += f" ({len(self.non_verifiables)} non vérifiable(s), non comptés)" return entete + "\n" + "\n".join(lignes) @@ -185,10 +255,93 @@ def normaliser_criteres(bruts) -> list[Critere]: valeur=valeur, attendu=str(attendu) if attendu is not None else None, description=str(brut.get("description") or "").strip(), + # `satisfait_d_avance` n'est JAMAIS lu depuis l'entrée : c'est un + # constat d'évaluation calculé depuis l'état initial, pas un champ + # que le Planner (ou quiconque) peut s'attribuer à lui-même. )) return criteres +def cle_critere(critere) -> tuple: + """ + Identité d'un critère pour la déduplication de l'union des contrats. + + La description n'en fait PAS partie : deux plans peuvent formuler la même + exigence différemment, et c'est l'exigence (type + cible + motif) qui + compte. En revanche `attendu` en fait partie : changer le motif attendu + d'un `fichier_contient` change l'exigence elle-même — ce n'est pas un + doublon, c'est une modification qui doit rester visible. + """ + return (critere.type, critere.valeur, critere.attendu) + + +def union_contrats(listes_criteres: list[list]) -> list[dict]: + """ + [Incident du 17/08] Union des contrats de l'historique. + + Un plan correctif peut ENRICHIR le contrat, jamais le remplacer ni + l'affaiblir : chaque critère déjà posé est conservé. La déduplication + s'appuie sur `cle_critere` ; la PREMIÈRE occurrence dans l'ordre + chronologique gagne, pour préserver la formulation du contrat approuvé et + son état initial constaté. + + L'ordre de la liste retournée place les critères du contrat le PLUS + RÉCENT en tête, puis ceux des contrats plus anciens — l'ordre ne change + rien au verdict (tous sont évalués), mais conserve la forme d'avant pour + le premier élément et présente d'abord l'état visé par la dernière + correction. Le PLANCHER, lui, ne se reconnaît pas à l'ordre de cette + liste : c'est la première entrée de contrat de l'historique. + + Accepte des listes de dicts bruts (format des metadata), en ordre + chronologique ; retourne des dicts au même format. + """ + union: dict[tuple, tuple[int, dict]] = {} + for position, liste in enumerate(listes_criteres): + for brut in liste or []: + normalises = normaliser_criteres(brut) + if not normalises: + continue + cle = cle_critere(normalises[0]) + # setdefault : la première occurrence chronologique gagne, pour + # préserver la formulation du contrat approuvé. + union.setdefault(cle, (position, dict(brut))) + # Contrat le plus récent d'abord ; au sein d'un même contrat, l'ordre + # chronologique d'origine est conservé. + return [brut for _position, brut in sorted(union.values(), key=lambda p: -p[0])] + + +def constater_etat_initial(criteres_bruts) -> dict[int, bool]: + """ + [Incident du 17/08] Constate l'état des critères fichier AU MOMENT où le + plan est posé, AVANT tout travail du lot. + + Retourne `{index du critère: vrai si déjà satisfait}`. Un critère absent + du constat (type non constatable, résolution impossible…) n'est PAS + considéré satisfait d'avance : le doute doit rendre le jugement plus + strict, jamais plus permissif. + + Appelée par le Planner à la pose du plan et par le service de reprise à la + restauration du contrat approuvé — dans les deux cas à un instant qui + précède l'exécution, ce qui fait du constat une photographie d'avant-travail. + + Accepte des dicts bruts ou des `Critere` déjà normalisés (le Planner vient + de normaliser : on ne re-normalise pas deux fois, les index doivent + correspondre terme à terme à la liste évaluée plus tard). + """ + normalises = ( + criteres_bruts + if criteres_bruts and isinstance(criteres_bruts[0], Critere) + else normaliser_criteres(criteres_bruts) + ) + etats: dict[int, bool] = {} + for index, critere in enumerate(normalises): + if critere.type not in TYPES_CONSTATABLES: + continue + resultat = _verifier_fichier(critere) + etats[index] = resultat.satisfait is True + return etats + + def _commande_style_windows(commande: str) -> bool: """Vrai si la commande commence par un chemin absolu Windows (`C:\\...`). @@ -314,10 +467,40 @@ def _racine_moteur() -> str: return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -def verifier_contrat(criteres, racine: str | None = None) -> RapportContrat: +def _marquer_si_satisfait_d_avance( + index: int, critere: Critere, resultat: ResultatCritere, + etats_initiaux: dict[int, bool] | None, +) -> ResultatCritere: + """ + Si le constat d'état initial dit que ce critère était déjà vrai avant le + lot, retourne un résultat portant un critère marqué `satisfait_d_avance` + (le verdict réel et le détail sont conservés pour le rapport et le journal). + """ + if etats_initiaux and etats_initiaux.get(index) and not critere.satisfait_d_avance: + marque = Critere( + type=critere.type, valeur=critere.valeur, attendu=critere.attendu, + description=critere.description, satisfait_d_avance=True, + ) + return ResultatCritere( + marque, resultat.satisfait, + f"{resultat.detail} — constaté satisfait AVANT le début du travail", + ) + return resultat + + +def verifier_contrat( + criteres, + racine: str | None = None, + etats_initiaux: dict[int, bool] | None = None, +) -> RapportContrat: """ Exécute tous les critères et retourne le verdict d'ensemble. + `etats_initiaux` (facultatif) : constat `{index: déjà satisfait}` posé à + la soumission du plan. Les critères déjà vrais à cet instant sont marqués + « satisfaits d'avance » : évalués et visibles, mais exclus du verdict — + un critère vrai avant le travail n'est pas une preuve de travail. + Ne lève jamais : un critère qui explose devient « non vérifiable », parce qu'une exception ici bloquerait la boucle de revue au lieu de l'informer. """ @@ -325,20 +508,29 @@ def verifier_contrat(criteres, racine: str | None = None) -> RapportContrat: criteres = normaliser_criteres(criteres) rapport = RapportContrat() - for critere in criteres or []: + for index, critere in enumerate(criteres or []): if critere.type == "commande": - rapport.resultats.append(_verifier_commande(critere, racine)) + resultat = _verifier_commande(critere, racine) elif critere.type in ("fichier_contient", "fichier_existe"): - rapport.resultats.append(_verifier_fichier(critere)) + resultat = _verifier_fichier(critere) else: - rapport.resultats.append(ResultatCritere( + resultat = ResultatCritere( critere, None, f"type de critère inconnu : {critere.type!r}" - )) + ) + rapport.resultats.append( + _marquer_si_satisfait_d_avance(index, critere, resultat, etats_initiaux) + ) if rapport.resultats: + exclus = len(rapport.resultats_satisfaits_d_avance) + note = f", {exclus} satisfait(s) d'avance (exclu(s) du verdict)" if exclus else "" + if rapport.echecs_satisfaits_d_avance: + # Un critère vrai avant le lot qui échoue maintenant : régression. + # Elle bloque le verdict — le doute va toujours vers le strict. + note += f", dont {len(rapport.echecs_satisfaits_d_avance)} en RÉGRESSION (bloquant(s))" logger.info( f"[CONTRAT] {len(rapport.verifiables)} critère(s) vérifiable(s), " - f"{len(rapport.echecs)} échec(s), {len(rapport.non_verifiables)} non vérifiable(s) " - f"→ satisfait={rapport.satisfait}" + f"{len(rapport.echecs)} échec(s), {len(rapport.non_verifiables)} non vérifiable(s)" + f"{note} → satisfait={rapport.satisfait}" ) return rapport diff --git a/core/app_state.py b/core/app_state.py index 106b31f..e8a96e4 100644 --- a/core/app_state.py +++ b/core/app_state.py @@ -14,11 +14,78 @@ import asyncio import logging +import os import threading from typing import Any, Optional logger = logging.getLogger(__name__) +# ── Verrou d'exécution PAR SESSION (#T324) ────────────────────────────── +# Anciennement un unique verrou global : toute exécution (IHM, vocal, DAG) +# était sérialisée et renvoyait 409 aux autres. Désormais la granularité est +# la SESSION : deux sessions différentes peuvent s'exécuter en parallèle, +# une même session ne s'exécute jamais deux fois (garde-fou anti-doublon). +# +# Le plafond de concurrence global reste nécessaire : le Deck est une machine +# modeste qui fait tourner la prod 24/7, « plus de 409 » ne doit pas se payer +# en saturation. Au-delà du plafond, le 409 reste la bonne réponse, avec un +# message qui distingue plafond et doublon de session. +# +# Variable d'environnement : MOTEUR_EXECUTION_MAX_CONCURRENCY (défaut 3). +# - 1 : revient au comportement historique (une exécution à la fois). +# - 3 : IHM + vocal + un DAG de fond peuvent tourner en parallèle. +# - >3 : réservé aux machines plus puissantes que le Deck. +def _max_concurrence() -> int: + brut = os.environ.get("MOTEUR_EXECUTION_MAX_CONCURRENCY") + if brut: + try: + demande = int(brut) + except ValueError: + logger.warning( + f"[T324] MOTEUR_EXECUTION_MAX_CONCURRENCY illisible ({brut!r}) — défaut 3 retenu." + ) + return 3 + if demande < 1: + logger.warning( + f"[T324] MOTEUR_EXECUTION_MAX_CONCURRENCY={demande} invalide (<1) — défaut 3 retenu." + ) + return 3 + return demande + return 3 + + +def _cle_execution(session_id: str | None, source=None) -> str: + """Clé d'exécution (#T324) : identité de la session dans le registre. + + Ordre de priorité : + 1. `session_id` fourni par le client (champ dédié du corps de requête) ; + 2. à défaut, la source de la requête (device_id, sinon type:mode) — + c'est ce qui permet au Tab5 de rejouer une commande vocale sur le + même device et d'être bloqué en doublon. + + Ne PAS confondre avec le `session_id` d'audit/BDD (unique par requête, + généré en interne) : celui-ci est la CLÉ de concurrence, stable. + """ + if session_id and str(session_id).strip(): + return str(session_id).strip() + if source is not None: + device_id = getattr(source, "device_id", None) + if device_id: + return f"src:{getattr(source, 'type', 'unknown')}:{device_id}" + return f"src:{getattr(source, 'type', 'unknown')}:{getattr(source, 'mode', 'default')}" + return "src:unknown:default" + + +# Messages 409 distincts : doublon de session ≠ plafond de concurrence. +MSG_DOUBLON_SESSION = ( + "Une exécution est déjà en cours pour cette session. " + "Attendez sa fin ou utilisez /api/stop." +) +MSG_PLAFOND_CONCURRENCE = ( + "Plafond de concurrence atteint (MOTEUR_EXECUTION_MAX_CONCURRENCY). " + "Réessayez dans quelques instants." +) + # Import conditionnel de TTLCache (avec fallback dict) try: from cachetools import TTLCache @@ -70,13 +137,31 @@ def initialize(self) -> None: return # ── État d'exécution ── + # Vue AGRÉGÉE (#T324) : `status == "running"` dès qu'au moins une + # exécution tourne. Garde exactement sa forme historique (4 clés) pour + # ne casser aucun consommateur (GET /api/status, stop, abort, Dreamer…). self.execution_state: dict[str, Any] = { "status": "idle", # "idle" | "running" | "success" | "error" "objective": "", "engine_state": None, # GlobalState sérialisé "error_message": None, } - # Lock asyncio pour les écritures concurrentes sur execution_state + # Registre des exécutions EN COURS, indexé par clé de session (#T324). + # Une entrée est créée par begin_execution() et TOUJOURS retirée par + # end_execution() (try/finally) : aucune fuite d'entrée, sinon la + # session serait bloquée pour toujours. + self.execution_registry: dict[str, dict[str, Any]] = {} + # Compteur de REQUÊTES EN VOL, distinct du registre de concurrence + # (#T344). Le plafond de concurrence ne protège que run_full_pipeline, + # mais le dreamer doit rester silencieux pendant TOUTE activité + # utilisateur (chemins légers compris). Ce compteur maintient + # execution_state["status"] == "running" tant qu'une requête tourne, + # sans jamais influencer le plafond de concurrence. + self.active_requests: int = 0 + # Plafond de concurrence global (variable d'environnement). + self.max_concurrency: int = _max_concurrence() + # Lock asyncio pour les écritures concurrentes sur execution_state et + # execution_registry (l'état est manipulé depuis la boucle d'événements). self.execution_lock = asyncio.Lock() # ── Engine singleton ── @@ -194,6 +279,152 @@ async def broadcast_sse(self, event_type: str, data: Any) -> None: except Exception: pass # Client déconnecté — retiré via sse_lock au cleanup + # ────────────────────────────────────────────────────────────── + # Cycle de vie d'une exécution PAR SESSION (#T324) + # ────────────────────────────────────────────────────────────── + + def _refresh_aggregate_locked(self, status: str = "success", error_message: str | None = None) -> None: + """Reconstruit la vue agrégée de execution_state (appelé sous execution_lock). + + Règle (#T324) : `status == "running"` dès qu'au moins une exécution + tourne dans le registre. Quand la dernière se termine, on applique le + statut terminal (success/error) et le message d'erreur éventuel. + + Règle (#T344) : `status == "running"` aussi tant que le compteur de + requêtes en vol (`active_requests`) est positif — les chemins légers + (casual_chat, HA déterministe, fuzzy, repli LLM, small-talk) ne prennent + aucun créneau du plafond mais doivent quand même maintenir le dreamer + silencieux pendant l'activité utilisateur. + """ + if self.active_requests > 0 or self.execution_registry: + self.execution_state["status"] = "running" + return + self.execution_state["status"] = status + self.execution_state["error_message"] = error_message + + async def check_duplicate(self, session_key: str) -> dict | None: + """Vérifie le doublon de session SANS consommer de créneau (#T344). + + Lecture seule du registre : renvoie le dict d'erreur 409 si la clé est + déjà en vol, sinon None. Ne vérifie PAS le plafond de concurrence : un + chemin léger ne doit jamais être refusé parce que le plafond est saturé + par des exécutions lourdes. Utilisée en amont d'`analyze_request` pour + ne pas payer le routeur (LLM) pour une requête qui sera de toute façon + refusée en doublon. La protection réelle reste `begin_execution`, qui + re-vérifie atomiquement sous lock juste avant le pipeline. + """ + async with self.execution_lock: + if session_key in self.execution_registry: + return {"status_code": 409, "detail": MSG_DOUBLON_SESSION} + return None + + async def begin_execution(self, session_key: str, objective: str) -> dict | None: + """Tente de démarrer une exécution pour la session. + + Args: + session_key : Clé de session (#T324) — identité de concurrence. + objective : Objectif affiché dans la vue agrégée. + + Returns: + None si l'exécution est acceptée (entrée enregistrée dans le + registre), sinon un dict d'erreur HTTP : {"status_code": 409, + "detail": ...} — doublon de session ou plafond atteint. + """ + async with self.execution_lock: + if session_key in self.execution_registry: + return {"status_code": 409, "detail": MSG_DOUBLON_SESSION} + if len(self.execution_registry) >= self.max_concurrency: + return {"status_code": 409, "detail": MSG_PLAFOND_CONCURRENCE} + self.execution_registry[session_key] = { + "status": "running", + "objective": objective, + "engine_state": None, + "error_message": None, + } + self.execution_state.update({ + "status": "running", + "objective": objective, + "engine_state": None, + "error_message": None, + }) + return None + + async def end_execution( + self, + session_key: str, + status: str = "success", + engine_state=None, + error_message: str | None = None, + ) -> None: + """Libère l'entrée de la session (idempotent, à appeler en try/finally). + + Retire l'entrée du registre puis rafraîchit la vue agrégée. Si une + autre exécution tourne encore, la vue reste "running" ; sinon elle + prend le statut terminal — celui fourni par défaut, ou celui déjà + enregistré sur l'entrée si un chemin (SSE erreur/déconnexion) l'a posé. + """ + async with self.execution_lock: + entry = self.execution_registry.pop(session_key, None) + if entry is not None and entry.get("status") in ("success", "error"): + status = entry["status"] + if entry.get("error_message"): + error_message = entry.get("error_message") + if not self.execution_registry: + self.execution_state["engine_state"] = engine_state + self._refresh_aggregate_locked(status=status, error_message=error_message) + + async def set_execution_status(self, session_key: str, status: str) -> None: + """Met à jour le statut d'une exécution en cours (fin d'étape, etc.). + + Ne change la vue agrégée que si c'est la dernière exécution active et + que le registre se vide (sinon "running" doit rester affiché). + """ + async with self.execution_lock: + entry = self.execution_registry.get(session_key) + if entry is not None: + entry["status"] = status + self._refresh_aggregate_locked(status=status) + + async def begin_request(self, objective: str = "") -> None: + """Compte une requête utilisateur en vol (#T344). + + Distinct de `begin_execution` : ce compteur ne participe PAS au plafond + de concurrence (ne refuse jamais une requête) et ne crée aucune entrée + dans `execution_registry`. Il sert uniquement de contre-pression au + dreamer : tant qu'une requête tourne — chemin léger ou pipeline complet — + `execution_state["status"]` reste "running" et le dreamer se tait. + + Doit être apparié à un appel `end_request` (try/finally). + """ + async with self.execution_lock: + self.active_requests += 1 + self.execution_state["status"] = "running" + if objective: + self.execution_state["objective"] = objective + + async def end_request(self) -> None: + """Décrémente le compteur de requêtes en vol et rafraîchit la vue. + + Idempotent : ne passe jamais en négatif. Si plus aucune requête ni + exécution ne tourne, la vue agrégée retombe sur "idle". + """ + async with self.execution_lock: + if self.active_requests > 0: + self.active_requests -= 1 + self._refresh_aggregate_locked(status="idle") + + async def clear_executions(self, status: str = "error", error_message: str | None = None) -> None: + """Libère TOUTES les exécutions en cours (arrêt global, abort). + + Utilisé par /api/stop et /api/vocal/abort pour débloquer l'état sans + laisser d'entrée orpheline dans le registre (garde-fou anti-fuite). + """ + async with self.execution_lock: + self.execution_registry.clear() + self.execution_state["status"] = status + if error_message is not None: + self.execution_state["error_message"] = error_message + # ────────────────────────────────────────────────────────────────── # Instance globale et fonctions d'accès diff --git a/core/budget_guard.py b/core/budget_guard.py index 6f3fb22..ed7ccbe 100644 --- a/core/budget_guard.py +++ b/core/budget_guard.py @@ -244,7 +244,7 @@ async def get_available_provider(self) -> str | None: 1. Rotation round-robin parmi les API cloud gratuites/abonnements disponibles (gemini-cli-abo, claude-cli-abo, cerebras-free, - dashscope-coding, cohere-free, mistral-free, gemini-free) — + cohere-free, mistral-free, gemini-free) — "lisse" l'usage entre providers au lieu qu'un seul domine systématiquement. 2. Ollama/LM Studio en FILET DE SÉCURITÉ (local, gratuit, mais après @@ -320,11 +320,7 @@ def query_db_status() -> tuple[int, int, float]: cloud_candidates.append("claude-cli-abo") if not _in_cooldown("cerebras-free") and os.getenv("CEREBRAS_API_KEY"): cloud_candidates.append("cerebras-free") - # Coding Plan Alibaba (Lite) — forfait requêtes, amorti comme un abo. - if not _in_cooldown("dashscope-coding") and ( - os.getenv("DASHSCOPE_API_KEY") or os.getenv("BAILIAN_CODING_PLAN_API_KEY") - ): - cloud_candidates.append("dashscope-coding") + # D-8 Axel 13/08 : Dashscope hors rotation (clé 401 + CGU backend). if not _in_cooldown("cohere-free") and os.getenv("COHERE_API_KEY"): cloud_candidates.append("cohere-free") if not _in_cooldown("mistral-free") and os.getenv("MISTRAL_API_KEY"): diff --git a/core/daemon_loop.py b/core/daemon_loop.py index 56cdcc3..43fda34 100644 --- a/core/daemon_loop.py +++ b/core/daemon_loop.py @@ -89,7 +89,7 @@ def _check_git_status() -> dict[str, Any]: result = {"check": "git_status", "status": "ok", "details": {}} try: - # Vérifier dans le répertoire parent (e:\AuxFilsDesIdees) + # Vérifier dans le répertoire parent (.) workspace = os.path.dirname(_ENGINE_ROOT) # Chercher les répertoires Git dans le workspace @@ -165,9 +165,9 @@ async def _check_ha_health() -> dict[str, Any]: # Entités critiques à surveiller (Tab5, DAC, Voice Assistant) critical_entities = [ - "switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active", - "sensor.m5stack_tab5_home_assistant_hmi_tab5_core_temp", - "media_player.m5stack_tab5_home_assistant_hmi_tab5_media_player", + "switch.example_wake_word", + "sensor.tablet_cpu_temp", + "media_player.tablet", ] try: @@ -347,7 +347,7 @@ async def _execute_freebox_ssh_command(command: str) -> tuple[int, str, str]: en debug seul a masqué la vraie cause en production (#T266). """ ssh_user = os.environ.get("SSH_USER", "axel") - freebox_ip = "192.168.1.x" + freebox_ip = "192.168.1.10" # 1. Tentative SSH système par clé — la raison d'échec est conservée pour # être visible si tout le chemin échoue. diff --git a/core/dag_runner.py b/core/dag_runner.py index 4eadcd7..0191948 100644 --- a/core/dag_runner.py +++ b/core/dag_runner.py @@ -18,6 +18,7 @@ import inspect import json import logging +import os import time from typing import TYPE_CHECKING @@ -46,6 +47,140 @@ # 40 000 caractères ≈ 10 000 tokens — garantit que le contexte d'une tâche ne sature jamais le prompt LLM TOTAL_CONTEXT_MAX_CHARS = 40_000 +# Délai par défaut du watchdog asynchrone d'une tâche DAG, si absent de +# config.json et de l'environnement. Inchangé par rapport au comportement +# historique (120 s) pour ne surprendre personne : c'est la valeur qui +# s'appliquait en dur avant la mise en configuration (#T380). +DEFAULT_DAG_TASK_TIMEOUT_S = 120.0 + +# Nom de la clé dans config.json pour le délai de base (en secondes). +CONFIG_DAG_TASK_TIMEOUT_KEY = "dag_task_timeout_s" + +# Nom de la clé dans config.json pour le délai par tier de modèle +# (dict `{"leger": 120, "moyen": 180, "fort": 300, ...}` en secondes). +CONFIG_DAG_TASK_TIMEOUT_BY_TIER_KEY = "dag_task_timeout_by_tier" + +# Variable d'environnement surchargeant le délai de base. +ENV_DAG_TASK_TIMEOUT_S = "MOTEUR_DAG_TASK_TIMEOUT_S" + +# Préfixe des variables d'environnement surchargeant le délai d'un tier donné. +# Ex. `MOTEUR_DAG_TASK_TIMEOUT_TIER_FORT_S=300` surcharge le tier "fort". +ENV_DAG_TASK_TIMEOUT_TIER_PREFIX = "MOTEUR_DAG_TASK_TIMEOUT_TIER_" + +# Tiers de modèle reconnus (même vocabulaire que `config.json` → `tiers`). +_TIERS_RECONNUS = ("leger", "moyen", "fort", "automatique") + + +def _lire_flottant_config( + config: dict, cle: str, defaut: float, prefixe_env: str | None = None +) -> float: + """ + Lit une durée (en secondes) depuis config.json, puis depuis l'environnement. + + Précédence : variable d'environnement > config.json > `defaut`. Une valeur + illisible (non numérique, négative) est ignorée avec un avertissement au + lieu de faire planter le moteur : le watchdog reste actif avec le défaut. + """ + valeur = None + if prefixe_env: + brut_env = os.environ.get(prefixe_env, "").strip() + if brut_env: + try: + valeur = float(brut_env) + except ValueError: + logger.warning( + f"[DAG] ⚠️ {prefixe_env} illisible ({brut_env!r}) : valeur config/défaut retenue." + ) + if valeur is None: + brut_config = config.get(cle) + if brut_config is not None: + try: + valeur = float(brut_config) + except (TypeError, ValueError): + logger.warning( + f"[DAG] ⚠️ config.json '{cle}' illisible ({brut_config!r}) : défaut retenu." + ) + if valeur is None or valeur <= 0: + return defaut + return valeur + + +def _delai_watchdog_seconds(task_payload: "TaskPayload", config: dict | None = None) -> float: + """ + Délai du watchdog asynchrone pour UNE tâche du DAG (en secondes). + + Le délai n'est plus un littéral unique : il dépend du tier de modèle de la + tâche, pour ne pas tuer une lecture de sources + synthèse LLM de tier fort + (ex. `explore_code`, `read_source_files`) qui dépasse 120 s sans rien avoir + d'anormal, tout en gardant le watchdog serré sur les tâches triviales. + + Précédence : + 1. surcharge explicite de la tâche (`watchdog_timeout_s` dans les metadata) ; + 2. délai par tier : variable d'environnement `MOTEUR_DAG_TASK_TIMEOUT_TIER__S`, + puis `dag_task_timeout_by_tier` de config.json ; + 3. délai de base : `MOTEUR_DAG_TASK_TIMEOUT_S`, puis `dag_task_timeout_s` de + config.json ; + 4. défaut inchangé : 120 s. + + Le watchdog n'est JAMAIS désactivé ici : `timeout=None` transformerait un + lot raté en lot éternel, ce qui est pire (garde-fou #T380). + """ + if config is None: + try: + from core.llm_gateway import load_config + config = load_config() + except Exception: + config = {} + + # 1. Surcharge explicite du payload (délai par tâche, pas par tier). + surcharge = (task_payload.metadata or {}).get("watchdog_timeout_s") + if surcharge is not None: + try: + valeur = float(surcharge) + if valeur > 0: + return valeur + except (TypeError, ValueError): + pass + + # 2. Délai par tier de modèle de la tâche. + tier = (task_payload.metadata or {}).get("model_tier") + if tier and str(tier).strip().lower() in _TIERS_RECONNUS: + tier_cle = str(tier).strip().lower() + # Variable d'environnement d'abord : `MOTEUR_DAG_TASK_TIMEOUT_TIER_FORT_S`. + brut_env = os.environ.get(ENV_DAG_TASK_TIMEOUT_TIER_PREFIX + tier_cle.upper() + "_S", "").strip() + if brut_env: + try: + valeur = float(brut_env) + if valeur > 0: + return valeur + except ValueError: + logger.warning( + f"[DAG] ⚠️ {ENV_DAG_TASK_TIMEOUT_TIER_PREFIX}{tier_cle.upper()}_S " + f"illisible ({brut_env!r}) : défaut retenu." + ) + # Puis config.json → `dag_task_timeout_by_tier`. + par_tier = config.get(CONFIG_DAG_TASK_TIMEOUT_BY_TIER_KEY) + if isinstance(par_tier, dict): + valeur = par_tier.get(tier_cle) + if valeur is not None: + try: + flottant = float(valeur) + if flottant > 0: + return flottant + except (TypeError, ValueError): + logger.warning( + f"[DAG] ⚠️ config.json '{CONFIG_DAG_TASK_TIMEOUT_BY_TIER_KEY}.{tier_cle}' " + f"illisible ({valeur!r}) : défaut retenu." + ) + + # 3. Délai de base, puis 4. défaut. + return _lire_flottant_config( + config, + CONFIG_DAG_TASK_TIMEOUT_KEY, + DEFAULT_DAG_TASK_TIMEOUT_S, + prefixe_env=ENV_DAG_TASK_TIMEOUT_S, + ) + class DAGRunner(MapReduceMixin, SubgraphMixin): """ @@ -671,21 +806,34 @@ async def _run_single_task( # Invocation protégée — les erreurs HTTP (413, 429, timeout) # ne doivent pas crasher tout le DAG mais mettre la tâche en erreur individuelle. + # [#T380] Le délai du watchdog est configurable (config.json + env) et peut + # être allongé par tier de modèle : une lecture de sources + synthèse LLM de + # tier fort dépasse 120 s sans rien avoir d'anormal (ex. `explore_code`, + # `read_source_files`, perdus le 17/08 sur ce watchdog unique). + timeout_s = _delai_watchdog_seconds(task_payload) + t0 = time.monotonic() try: # Dispatch Swarm : tenter le déport vers un worker distant - # Ajout du Circuit Breaker Asynchrone (Watchdog 120s) + # Ajout du Circuit Breaker Asynchrone (Watchdog configurable) t_upd = await asyncio.wait_for( try_swarm_dispatch( self._engine.state.session_id, task_id, task_payload, target_name, target_agent, on_event ), - timeout=120.0 + timeout=timeout_s ) except TimeoutError: - logger.error(f"[DAG] ⏱️ Watchdog déclenché : Timeout de 120s dépassé pour la tâche '{task_id}' (Agent: '{target_name}')") + duree_reelle = time.monotonic() - t0 + logger.error( + f"[DAG] ⏱️ Watchdog déclenché : délai de {timeout_s}s dépassé pour la tâche " + f"'{task_id}' (Agent: '{target_name}') après {duree_reelle:.1f}s d'exécution." + ) t_upd = StateUpdate( agent_name=target_name, status="error", - error_message="Timeout de 120s dépassé (Circuit Breaker Asynchrone)", + error_message=( + f"Timeout de {timeout_s}s dépassé (Circuit Breaker Asynchrone) " + f"— durée réelle {duree_reelle:.1f}s, agent '{target_name}'" + ), result_data=None, metadata={}, ) diff --git a/core/engine.py b/core/engine.py index 97faa16..6c9e723 100644 --- a/core/engine.py +++ b/core/engine.py @@ -52,7 +52,18 @@ HITL_SIDE_EFFECT_AGENTS = {"ha_agent"} # Préfixes de sessions AUTONOMES (aucun humain présent) → bypass d'office, # sinon le flux stallerait jusqu'à l'auto-approbation au timeout (5 min). -HITL_AUTONOMOUS_PREFIXES = ("daemon_", "dreamer_", "routine_", "maint_") +# `task_` = tâche de backlog jouée par DreamCoder la nuit (`agents/dreamer_agent.py`, +# `session_id = f"task_{task_id}"`). Il manquait, et c'était le blocage de fond de +# la session autonome : DreamCoder est le SEUL de ces flux qui écrit vraiment du +# code, donc le seul dont tous les plans touchent write_file / run_terminal_command +# — c'est-à-dire dont tous les plans sont « à risque ». Mesuré le 18/08 sur le +# Deck : 4 tâches lancées, 4 plans corrects produits par le Planner (contrat +# d'acceptation compris), 4 sorties en `waiting_approval` après 6 s, 4 rollbacks. +# Personne ne pouvait approuver : le cycle tourne à 3 h du matin. +# Le garde-fou n'est pas le HITL ici, c'est la chaîne d'après : le travail se fait +# dans un clone dédié, sur une branche `task/*`, et n'entre dans `master` que par +# une PR relue à la main. +HITL_AUTONOMOUS_PREFIXES = ("daemon_", "dreamer_", "routine_", "maint_", "task_") # Préfixes de sessions INTERACTIVES (un humain est présent, l'IHM gère l'approbation). HITL_INTERACTIVE_PREFIXES = ("chat_", "stream_", "gui_session_") @@ -102,6 +113,15 @@ def __init__(self, session_id: str, context_manager=None, repo_root: str | None # branche éphémère — le travail approuvé y attend sa décision. self._suspension_request_id: str | None = None self._git_branch_courante: str | None = None + # [#T379] Cause de l'échec décidée par le moteur (DAG en erreur, revue + # rejetée, contrat non satisfait, rejet HITL, budget/plafond). Une fin + # d'exécution doit dire POURQUOI elle a échoué : le booléen has_error + # seul perd cette information. Les chemins qui terminent l'exécution + # (run_full_pipeline en tête, approval_resume_service en reprise) la + # lisent pour écrire le statut terminal avec un message qui nomme la + # cause — incident du 17/08 : trois exécutions terminées sans que leur + # session ne porte jamais ni statut terminal ni date de fin. + self.cause_echec: str | None = None # Workflow-as-Code : transitions dynamiques depuis le graphe JSON from core.workflow_executor import WorkflowExecutor self._workflow_executor = WorkflowExecutor() @@ -222,6 +242,8 @@ async def run(self, initial_payload: TaskPayload, starting_agent: str) -> Global git_branch = None has_error = False tasks_status: dict = {} + # [#T379] Nouvelle exécution : le verdict précédent ne doit pas survivre. + self.cause_echec = None # [P2-3.4] Budget global d'exécution (tokens + durée + coût), partagé avec # le DAG runner pour plafonner TOUTE la requête. Durée/coût opt-in via config. @@ -312,6 +334,12 @@ async def _run_sequential_agents( "current_agent": current_agent_name, }) has_error = True + # [#T379] Nommer la cause : la session doit pouvoir dire + # pourquoi elle s'est arrêtée. + self.cause_echec = ( + f"Plafond de boucle atteint ({MAX_AGENT_HANDOFFS} handoffs " + f"agent-à-agent) — arrêt anti-boucle." + ) break agent = self.agents.get(current_agent_name) @@ -334,6 +362,11 @@ async def _run_sequential_agents( budget.event_payload(violation, blocked=current_agent_name), ) has_error = True + # [#T379] Nommer la cause : la session doit pouvoir dire + # pourquoi elle s'est arrêtée. + self.cause_echec = ( + f"Budget d'exécution dépassé ({violation['reason']}) — arrêt propre." + ) break # Mise à jour de la phase @@ -437,6 +470,12 @@ async def _run_sequential_agents( ) if not wf_error_tasks: has_error = True + # [#T379] Nommer la cause : la session doit pouvoir dire + # pourquoi elle s'est arrêtée. + self.cause_echec = ( + f"Erreur de l'agent '{current_agent_name}' sans branche de " + f"récupération : {(update.error_message or 'détail indisponible')[:200]}" + ) break logger.info( @@ -556,6 +595,9 @@ async def _handle_dag_execution( if decision == DECISION_REJETE: logger.warning("[ENGINE] Plan rejeté par l'utilisateur (HITL).") + # [#T379] Le rejet humain est une fin d'exécution à part entière : + # nommer la cause pour que la session close dise ce qui s'est passé. + self.cause_echec = "Plan rejeté par l'utilisateur (HITL)." if self.on_event: await self.on_event("plan_rejected", { "reason": "Rejeté par l'utilisateur via HITL" @@ -588,13 +630,27 @@ async def executer_dag_et_controles( Returns: (tasks_status, has_error), comme `execute_dag`, mais après contrôles. + + [#T379] Chaque porte de sortie qui décide has_error nomme aussi sa + cause dans `self.cause_echec` : le chemin de reprise après approbation + n'a QUE ce retour pour écrire le statut terminal de la session, et un + booléen ne distingue pas un DAG en échec d'une revue rejetée ni d'un + contrat non satisfait. """ + # [#T379] Nouveau verdict : remise à plat de la cause (une exécution + # passée par une branche d'erreur #T219 peut très bien réussir ici). + self.cause_echec = None tasks_status, has_error = await self._dag_runner.execute_dag( tasks=dag_tasks, max_session_tokens=max_session_tokens, on_event=self.on_event, budget=budget, # [P2-3.4] budget partagé (tokens+durée+coût) ) + if has_error: + n_echecs = sum( + 1 for s in (tasks_status or {}).values() if s in ("error", "blocked") + ) + self.cause_echec = f"Échec d'exécution du DAG : {n_echecs} tâche(s) en erreur." # ── Review post-DAG ── if not has_error and tasks_status: @@ -606,6 +662,11 @@ async def executer_dag_et_controles( ) if not approved: has_error = True + # [#T379] Rejet du Reviewer : fin d'exécution négative, nommée. + self.cause_echec = ( + "Revue post-DAG rejetée par le Reviewer après épuisement " + "des rounds de correction." + ) elif has_error and tasks_status: # [#T253] DAG en échec : la revue LLM est sautée (payer un avis de # complaisance après un échec n'a pas de sens), mais le contrat @@ -617,6 +678,9 @@ async def executer_dag_et_controles( verdict = await self._review_loop.contrat_seul(on_event=self.on_event) if verdict is True: has_error = False + # [#T379] Le contrat a prouvé l'objectif atteint : plus d'échec, + # donc plus de cause d'échec. + self.cause_echec = None # Absoudre aussi les statuts d'erreur pour la finalisation Git : # `_finalize_git` exige que TOUTES les tâches soient « success », # sinon rollback même quand le contrat a prouvé l'objectif atteint. @@ -627,6 +691,13 @@ async def executer_dag_et_controles( "[ENGINE] [T253] Contrat satisfait malgré DAG en erreur — " "session et branche Git traitées comme succès." ) + elif verdict is False: + # [#T379] Le contrat CONFIRME l'échec : c'est lui la cause + # décisive (plus précise que « une tâche est en erreur »). + self.cause_echec = ( + "Contrat d'acceptation non satisfait après échec du DAG." + ) + # verdict None (rien de vérifiable) : la cause du DAG est conservée. # Enregistrement des skills après un DAG réussi if not has_error: @@ -1124,11 +1195,17 @@ def _criteres_acceptation_courants(self) -> list: prod le 11/08 : un plan approuvé fusionnait sans qu'aucun de ses 2 critères ne soit vérifié. - Même règle de lecture que `ReviewLoop._criteres_du_plan` : le plus - récent gagne, un plan correctif ayant pu en reposer un. + [Incident du 17/08] On lit le PREMIER contrat de l'historique, pas le + plus récent : c'est celui posé par le plan soumis à approbation, et + c'est lui qui deviendra le PLANCHER à la reprise (injecté en tête de + l'historique par `approval_resume_service`). Le HITL ne suspend et ne + rejoue que le plan d'origine — jamais un plan correctif — donc le + contrat persisté doit être celui qui est approuvé. (Avant ce + correctif, la docstring renvoyait vers « le plus récent gagne », la + règle même qui a laissé un plan correctif évincer le contrat approuvé.) """ from core.acceptance_contract import CLE_CONTRAT - for update in reversed(self.state.history): + for update in self.state.history: criteres = (update.metadata or {}).get(CLE_CONTRAT) if criteres: return criteres diff --git a/core/event_store.py b/core/event_store.py index b70ce63..554a7da 100644 --- a/core/event_store.py +++ b/core/event_store.py @@ -26,10 +26,29 @@ logger = logging.getLogger(__name__) -# Chemin par défaut relatif au répertoire moteur +# Chemin par défaut relatif au répertoire moteur (repli si runtime_db indisponible). +# [#T348] Ce chemin n'est PAS celui utilisé par défaut par EventStore : la +# résolution réelle passe par `_resolve_default_db_path()` (ci-dessous), qui +# délègue à `core.runtime_db.get_db_path()` À L'APPEL — pour que l'isolation +# des tests (`override_db_path`) s'applique aussi à l'Event Store. `DEFAULT_DB` +# reste conservé pour la compatibilité (rien d'autre ne l'importe). _ENGINE_DIR = Path(__file__).resolve().parent.parent DEFAULT_DB = str(_ENGINE_DIR / "moteur_runtime.db") + +def _resolve_default_db_path() -> str: + """Chemin par défaut de l'EventStore, résolu au moment de l'appel. + + Délègue à `core.runtime_db.get_db_path()` pour que l'isolation des tests + (`override_db_path`) s'applique aussi à l'Event Store. Ne JAMAIS figer ce + chemin en tête de module : ce serait reproduire le défaut #T348 (le chemin + calculé indépendamment de `runtime_db`, figeant l'EventStore sur la vraie + base du dépôt pendant toute la suite de tests). + """ + from core.runtime_db import get_db_path + + return get_db_path() + # SQL append-only _SQL_CREATE = """ CREATE TABLE IF NOT EXISTS events ( @@ -59,9 +78,10 @@ class EventStore: def __init__(self, db_path: str | None = None) -> None: """ Args: - db_path: Chemin vers le fichier SQLite (défaut: moteur_runtime.db) + db_path: Chemin vers le fichier SQLite (défaut: moteur_runtime.db, + résolu dynamiquement via core.runtime_db.get_db_path()). """ - self.db_path = db_path or DEFAULT_DB + self.db_path = db_path or _resolve_default_db_path() self._conn: sqlite3.Connection | None = None self._init_db() @@ -228,8 +248,22 @@ def close(self) -> None: def get_event_store(db_path: str = None) -> EventStore: - """Retourne le singleton EventStore (chargement lazy).""" + """Retourne le singleton EventStore (chargement lazy). + + - Si `db_path` est fourni, il est prioritaire sur le chemin par défaut. + - Sinon, le chemin par défaut est résolu À L'APPEL + (`core.runtime_db.get_db_path()`). Si la base a été réorientée entre-temps + (ex. `override_db_path` dans les tests), le singleton se réinitialise + automatiquement pour suivre le nouveau chemin : deux appels séparés par un + `override_db_path()` n'écrivent plus dans le même fichier (#T348). + """ global _event_store_instance - if _event_store_instance is None: - _event_store_instance = EventStore(db_path) + chemin = db_path if db_path is not None else _resolve_default_db_path() + if _event_store_instance is None or _event_store_instance.db_path != chemin: + # Une instance existe déjà mais pointe vers un autre fichier : on la + # ferme proprement avant de la remplacer, pour ne pas laisser une + # connexion SQLite ouverte sur l'ancien chemin. + if _event_store_instance is not None: + _event_store_instance.close() + _event_store_instance = EventStore(chemin) return _event_store_instance diff --git a/core/fenetres_execution.py b/core/fenetres_execution.py index 1ae5180..1f87876 100644 --- a/core/fenetres_execution.py +++ b/core/fenetres_execution.py @@ -100,6 +100,45 @@ def duree_session_fond_s() -> float: return 900.0 +def duree_tache_dreamcoder_s() -> float: + """ + Fenêtre d'UNE tâche autonome DreamCoder (nuit, backlog). + + Quatrième occurrence du défaut que ce module existe pour empêcher : + `_process_one_dreamcoder_task` enveloppait le pipeline dans un + `wait_for(timeout=600)` — 10 minutes affichées — mais appelait + `run_full_pipeline` SANS `timeout_seconds`, donc avec son défaut de 120 s + hérité du chemin interactif. L'enveloppe était décorative : le vrai plafond + d'une tâche de nuit était de 2 minutes, Planner compris. Aucune tâche de + code sérieuse ne tient là-dedans. + + Pourquoi 600 s et pas les 900 s de la session de fond : le cycle entier est + borné par `dreamcoder_max_cycle_minutes` (30 min par défaut), qui est le + budget de TOUTES les tâches de la nuit. À 900 s, une seule tâche lente + mangerait la moitié du cycle ; à 600 s il en reste pour trois. + """ + brut = os.environ.get("MOTEUR_DREAMCODER_TACHE_TIMEOUT_S") + if brut: + try: + return float(brut) + except ValueError: + logger.warning( + f"[FENETRES] MOTEUR_DREAMCODER_TACHE_TIMEOUT_S illisible ({brut!r}) — défaut retenu.") + return 600.0 + + +# Marge de l'enveloppe `wait_for` de DreamCoder au-dessus de la fenêtre du +# pipeline. Elle DOIT rester positive : c'est ce qui laisse le pipeline rendre +# son erreur « Timeout Xs » (et clore la session) au lieu d'être tué de +# l'extérieur, ce qui laisserait une session zombie (#T361). +MARGE_ENVELOPPE_DREAMCODER_S = 60.0 + + +def duree_enveloppe_dreamcoder_s() -> float: + """Borne extérieure d'une tâche DreamCoder — toujours au-dessus du pipeline.""" + return duree_tache_dreamcoder_s() + MARGE_ENVELOPPE_DREAMCODER_S + + def duree_session_interactive_s() -> float: """ Fenêtre d'une session interactive (chat, vocal) : un humain attend. @@ -137,6 +176,19 @@ def verifier_emboitement() -> list[str]: f"Session de fond ({session_fond}s) <= Planner ({planner}s) : " f"il ne resterait rien pour exécuter le DAG." ) + + tache_dreamcoder = duree_tache_dreamcoder_s() + if tache_dreamcoder <= planner: + violations.append( + f"Tâche DreamCoder ({tache_dreamcoder}s) <= Planner ({planner}s) : " + f"une tâche de nuit ne pourrait pas dépasser son propre plan." + ) + if duree_enveloppe_dreamcoder_s() <= tache_dreamcoder: + violations.append( + f"Enveloppe DreamCoder ({duree_enveloppe_dreamcoder_s()}s) <= tâche " + f"({tache_dreamcoder}s) : le pipeline serait tué avant de pouvoir " + f"rendre son erreur, laissant une session zombie." + ) return violations @@ -144,7 +196,8 @@ def journaliser_fenetres() -> None: """Trace la hiérarchie au démarrage, et signale toute incohérence.""" logger.info( f"[FENETRES] tentative LLM {duree_tentative_llm_s()}s < Planner {duree_planner_s()}s " - f"< session de fond {duree_session_fond_s()}s (interactive : {duree_session_interactive_s()}s)" + f"< session de fond {duree_session_fond_s()}s (interactive : {duree_session_interactive_s()}s) " + f"| tâche DreamCoder {duree_tache_dreamcoder_s()}s < enveloppe {duree_enveloppe_dreamcoder_s()}s" ) for violation in verifier_emboitement(): logger.error(f"[FENETRES] ⚠️ {violation}") diff --git a/core/gemini_native.py b/core/gemini_native.py index 4450ed5..41862a8 100644 --- a/core/gemini_native.py +++ b/core/gemini_native.py @@ -45,6 +45,21 @@ THOUGHT_SIGNATURE_INTERNAL_KEY = "_thought_signature" +def _http_error_detail(e: requests.exceptions.HTTPError) -> tuple[str, str]: + """Détaille une HTTPError pour le journal : (code HTTP, corps de réponse). + + Quand `e.response` est présent, on journalise le code et le corps comme + avant. Quand il est ABSENT (erreur de transport, DNS, timeout…), on ne peut + pas parler de « code HTTP » : on journalise donc le type et le message de + l'exception — sinon l'erreur réelle disparaît du journal (mesuré : 78 + occurrences de « Erreur HTTP ? » en 36 h, sans exception visible). + """ + if e.response is not None: + return str(e.response.status_code), e.response.text[:500] + # Pas de réponse : l'information vit dans l'exception elle-même. + return type(e).__name__, str(e) + + class GeminiStructuredError(RuntimeError): """Réponse structurée Gemini inexploitable — #T265. @@ -187,8 +202,7 @@ def create(self, system_content: str, ttl_seconds: int = 3600) -> str | None: return None except requests.exceptions.HTTPError as e: - status = e.response.status_code if e.response else "?" - body = e.response.text[:500] if e.response else "" + status, body = _http_error_detail(e) logger.warning( f"[GEMINI CACHE] ❌ Erreur HTTP {status} lors de la création du cache : {body}" ) @@ -693,8 +707,7 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: self._key_pool.report_success(active_key) except requests.exceptions.HTTPError as e: - status = e.response.status_code if e.response else "?" - body = e.response.text[:500] if e.response else "" + status, body = _http_error_detail(e) logger.error(f"[GEMINI NATIF] Erreur HTTP {status} : {body}") # Si c'est une erreur de cache (404 = cache expiré), retenter sans cache @@ -1075,8 +1088,7 @@ def generate_audio( return {"success": False, "error": "Aucune partie audio trouvée dans la réponse"} except requests.exceptions.HTTPError as e: - status = e.response.status_code if e.response else "?" - body = e.response.text[:300] if e.response else "" + status, body = _http_error_detail(e) logger.error(f"[GEMINI TTS] Erreur HTTP {status} : {body}") return {"success": False, "error": f"HTTP {status}: {body}"} except Exception as e: diff --git a/core/ha_fuzzy_matcher.py b/core/ha_fuzzy_matcher.py index 0558f96..6cd11d8 100644 --- a/core/ha_fuzzy_matcher.py +++ b/core/ha_fuzzy_matcher.py @@ -35,6 +35,16 @@ logger = logging.getLogger(__name__) + +def _est_entite_morte(entity_info: dict) -> bool: + """ + [T356] Une entité HA est « morte » quand son état est `unavailable` ou + `unknown` : l'appareil n'exécutera aucune commande. Une entité vivante doit + l'emporter sur une morte à score de correspondance voisin. + """ + return (entity_info.get("state") or "") in ("unavailable", "unknown") + + # Domaines HA éligibles au fuzzy match (commandes physiques uniquement) HA_ELIGIBLE_DOMAINS = {"light", "switch", "cover", "fan", "climate", "media_player", "input_boolean"} @@ -63,8 +73,16 @@ # Durée de vie du cache des entités en secondes ENTITY_CACHE_TTL = 60.0 +# [T356] Pénalité appliquée au score d'une entité `unavailable`/`unknown`. +# Strictement SUPÉRIEURE à FUZZY_AMBIGUITY_DELTA (0.08) : une morte pénalisée +# sort de la fenêtre d'ambiguïté avec la vivante, qui est alors choisie sans +# refus. Sans ça, un doublon mort (ex. six light.sonoff_* indisponibles) +# resterait dans la fenêtre et bloquerait tout choix. Reste modérée : une morte +# seule candidate à un score élevé (ex. 0.80 → 0.70) reste retenue. +_PENALITE_ENTITE_MORTE = 0.10 + # URL LM Studio pour les embeddings (PC dev par défaut ; override via .env Deck) -LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://192.168.1.x:1234").rstrip("/") +LM_STUDIO_URL = os.environ.get("LM_STUDIO_URL", "http://192.168.1.10:1234").rstrip("/") LM_STUDIO_EMBED_MODEL = "nomic-embed-text" LM_STUDIO_EMBED_TIMEOUT = 3.0 # secondes @@ -120,7 +138,7 @@ class HAFuzzyMatcher: def __init__(self, ha_url: str, ha_token: str): """ Args: - ha_url: URL de l'instance HA (ex: https://192.168.1.x:8123) + ha_url: URL de l'instance HA (ex: https://192.168.1.10:8123) ha_token: Long-lived access token HA """ self.ha_url = ha_url.rstrip("/") @@ -286,6 +304,10 @@ async def _load_entities(self) -> dict: "friendly_name": friendly_name, "normalized_name": self._normalize(friendly_name), "normalized_id": self._normalize(entity_id.split(".")[-1]), + # [T356] État brut (on/off/unavailable/unknown) : sert à + # départager deux entités de score voisin au profit de la + # vivante. + "state": state.get("state"), } self._entity_cache = cache @@ -320,7 +342,15 @@ def _score_entity(self, normalized_prompt: str, entity_info: dict) -> float: bonus = 0.15 if (entity_info["normalized_name"] and entity_info["normalized_name"] in normalized_prompt) else 0.0 - return min(1.0, max(score_name, score_id) + bonus) + score = min(1.0, max(score_name, score_id) + bonus) + # [T356] À score de correspondance égal ou très proche, une entité + # VIVANTE doit l'emporter sur une entité morte (doublons indisponibles + # qui concurrencent les entités réelles). La pénalité, strictement + # supérieure au delta d'ambiguïté, fait sortir la morte de la fenêtre : + # la vivante est choisie sans refus. + if _est_entite_morte(entity_info): + score = max(0.0, score - _PENALITE_ENTITE_MORTE) + return score @staticmethod def _is_ambiguous(scores: list[float], threshold: float) -> bool: diff --git a/core/ha_tls.py b/core/ha_tls.py index 8bfc5d3..98f00e6 100644 --- a/core/ha_tls.py +++ b/core/ha_tls.py @@ -12,7 +12,7 @@ - HA_TLS_SERVER_HOSTNAME → nom d'hôte à vérifier dans le certificat, quand `HASS_URL` vise une IP locale mais que le certificat ne couvre qu'un nom DNS (cas Freebox : certificat Let's Encrypt `axellum.freeboxos.fr` servi sur - 192.168.1.x:8123). Équivalent du `--resolve` de curl : on joint l'IP locale + 192.168.1.10:8123). Équivalent du `--resolve` de curl : on joint l'IP locale (rapide, sans dépendance WAN) tout en validant chaîne ET nom du certificat. ⚠️ DÉPLOIEMENT : si Home Assistant est en HTTPS avec un certificat auto-signé, @@ -28,11 +28,16 @@ _FALSY = {"0", "false", "no", "off", "non"} +# Le warning d'opt-out TLS n'est émis qu'une seule fois par démarrage : c'est un +# choix de configuration, pas un événement. Répété à chaque construction de +# contexte (jusqu'à ~644 fois en 36 h), il noierait le reste du journal. +_warning_optout_affiche = False + class _PinnedHostnameContext(ssl.SSLContext): """Contexte SSL qui vérifie le certificat contre un nom d'hôte imposé. - `HASS_URL` pointe une IP (192.168.1.x) alors que le certificat ne couvre + `HASS_URL` pointe une IP (192.168.1.10) alors que le certificat ne couvre qu'un nom DNS ; sans cela la vérification échoue sur « IP address mismatch ». Le nom présenté en SNI et vérifié est donc forcé ici, pour tous les appelants d'un coup — aucun site d'appel n'a à passer `server_hostname`, y compris @@ -97,11 +102,16 @@ def ha_ssl_context() -> ssl.SSLContext: return ctx # Opt-out explicite (HA_VERIFY_TLS=false) — seule désactivation TLS du projet. - logger.warning( - "[HA-TLS] Vérification TLS DÉSACTIVÉE (HA_VERIFY_TLS=false). " - "À réserver à un certificat auto-signé sur LAN de confiance ; " - "préférer HA_CA_BUNDLE." - ) + # Le warning est émis une seule fois par démarrage (premier appel) : le + # comportement TLS, lui, est identique à chaque appel. + global _warning_optout_affiche + if not _warning_optout_affiche: + _warning_optout_affiche = True + logger.warning( + "[HA-TLS] Vérification TLS DÉSACTIVÉE (HA_VERIFY_TLS=false). " + "À réserver à un certificat auto-signé sur LAN de confiance ; " + "préférer HA_CA_BUNDLE." + ) ctx = ssl.create_default_context() ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE diff --git a/core/ha_url.py b/core/ha_url.py index d8644fb..852e70f 100644 --- a/core/ha_url.py +++ b/core/ha_url.py @@ -10,7 +10,7 @@ Même famille, même style, même traitement de l'absence que `core/ha_token.py::get_ha_token()` (T239) : PAS de valeur par défaut. -Avant #T330, neuf sites portaient `http://192.168.1.x:8123` en dur comme +Avant #T330, neuf sites portaient `http://192.168.1.10:8123` en dur comme repli — une URL en clair vers un serveur qui n'écoute qu'en HTTPS. Un défaut faux est pire que pas de défaut : Home Assistant refoule le clair sans répondre (`RemoteDisconnected`, cf. #T329), et ces valeurs finissaient diff --git a/core/langfuse_bridge.py b/core/langfuse_bridge.py index 74ab699..9605e0e 100644 --- a/core/langfuse_bridge.py +++ b/core/langfuse_bridge.py @@ -8,7 +8,7 @@ Variables d'environnement requises (optionnelles) : LANGFUSE_PUBLIC_KEY — Clé publique du projet Langfuse LANGFUSE_SECRET_KEY — Clé secrète du projet Langfuse - LANGFUSE_HOST — URL du serveur Langfuse (ex: http://192.168.1.x:3000) + LANGFUSE_HOST — URL du serveur Langfuse (ex: http://192.168.1.10:3000) """ import logging @@ -56,7 +56,7 @@ def __init__(self): # Tentative d'initialisation public_key = os.environ.get("LANGFUSE_PUBLIC_KEY") secret_key = os.environ.get("LANGFUSE_SECRET_KEY") - host = os.environ.get("LANGFUSE_HOST", "http://192.168.1.x:3000") + host = os.environ.get("LANGFUSE_HOST", "http://192.168.1.10:3000") if not public_key or not secret_key: logger.info( diff --git a/core/llm/builtin_providers.py b/core/llm/builtin_providers.py index 172f2a6..c46571b 100644 --- a/core/llm/builtin_providers.py +++ b/core/llm/builtin_providers.py @@ -38,6 +38,10 @@ verifier_interface_transport, ) +# Modèle réellement chargé par AJEAN (llama.cpp / llama-server). Constante unique, +# partagée avec le registre core/openai_compat_provider.py — ne pas dupliquer ici. +from core.openai_compat_provider import AJEAN_DEFAULT_MODEL + _NOTES_EXPERIMENT = "Plan Experiment (Gratuit)" @@ -216,11 +220,11 @@ class LMStudioLocalPlugin(ProviderPlugin): name='LM Studio (Local)', type='local', transport='local', - api_endpoint='http://192.168.1.x:1234/v1/chat/completions', - auth=AuthSpec(kind='none', instructions='Aucune clé : serveur LM Studio sur le LAN (192.168.1.x:1234).'), + api_endpoint='http://192.168.1.10:1234/v1/chat/completions', + auth=AuthSpec(kind='none', instructions='Aucune clé : serveur LM Studio sur le LAN (192.168.1.10:1234).'), confidentiality='total', cascade_priority=1.0, - notes='RTX 5070 Ti (16GB VRAM), 9 modèles chargés. Coût zéro, confidentialité totale. Curation étape 2 : Zoo local LM Studio déclaré tel quel (9 modèles) : le contenu est dynamique par nature, la découverte live relève de discover_models. Signal : gemma-4-31b = 17 appels, seul modèle local réellement consommé.', + notes='GPU locale (16GB VRAM), 9 modèles chargés. Coût zéro, confidentialité totale. Curation étape 2 : Zoo local LM Studio déclaré tel quel (9 modèles) : le contenu est dynamique par nature, la découverte live relève de discover_models. Signal : gemma-4-31b = 17 appels, seul modèle local réellement consommé.', models=( ModelSpec( id='qwen2.5-14b-instruct-1m', @@ -336,7 +340,7 @@ class OllamaLocalPlugin(_PluginOpenAICompat): auth=AuthSpec(kind='none', instructions='Aucune clé : démon Ollama local (127.0.0.1:11434).'), confidentiality='total', cascade_priority=1.1, - notes="Ollama local sur RTX 5070 Ti. Inférence ultra-rapide. Curation étape 2 : 2 modèles du seed + le fine-tune domotique câblé mais absent du catalogue. ollama_pc (LAN 192.168.1.x) reste hors descripteur : c'est un accès distant au MÊME service, pas un provider distinct.", + notes="Ollama local sur GPU locale. Inférence ultra-rapide. Curation étape 2 : 2 modèles du seed + le fine-tune domotique câblé mais absent du catalogue. ollama_pc (LAN 192.168.1.10) reste hors descripteur : c'est un accès distant au MÊME service, pas un provider distinct.", models=( ModelSpec( id='qwen2.5-coder:7b', @@ -385,6 +389,56 @@ def build(self, credentials, model=None): +class AjeanPlugin(_PluginOpenAICompat): + """Provider AJEAN (llama.cpp / llama-server, port 8080). + + Déclaration purement déclarative : AJEAN n'est PAS encore mis en tête de cascade + (FAST_PATH_PROVIDERS intact, `services/pipeline_service.py` non modifié). L'hôte + n'écoute pas encore sur le LAN — action d'Axel (basculer llama-server sur 0.0.0.0 + + ouvrir le pare-feu 8080). D'ici là, `ajean_pc` échoue en connect timeout et la + cascade bascule en silence sur le cloud. + """ + + _DESCRIPTEUR = ProviderDescriptor( + id='ajean_pc', + name='AJEAN (llama.cpp PC via LAN)', + type='local', + transport='openai_compat', + api_endpoint='http://192.168.1.10:8080/v1/chat/completions', + auth=AuthSpec(kind='none', instructions='Aucune clé : llama.cpp n\'authentifie pas (API OpenAI-compatible, port 8080).'), + confidentiality='total', + cascade_priority=1.1, + notes="AJEAN = llama.cpp (llama-server) sur le PC local, Qwen2.5-14B-Instruct-1M-Q4_K_M. Déclaré par IP LAN (192.168.1.10), jamais par nom DNS, pour rester dans la famille locale (#T337, _est_hote_local). ajean_deck (127.0.0.1) est l'accès loopback au MÊME service — pas un provider distinct, d'où l'absence de descripteur propre (même logique qu'ollama_local/ollama_pc).", + models=( + ModelSpec( + id=AJEAN_DEFAULT_MODEL, + display_name='Qwen 2.5 14B Instruct 1M (AJEAN)', + tier='local', + context_input=32768, + context_output=8192, + supports_tools=True, + supports_json_mode=True, + supports_streaming=True, + speciality='domotique', + recommended_use='Inférence locale llama.cpp — contexte 1M, Q4_K_M sur GPU', + ), + ), + ) + + @property + def descriptor(self) -> ProviderDescriptor: + return self._DESCRIPTEUR + + def build(self, credentials, model=None): + # Pas de clé : llama.cpp n'authentifie pas (le gateway passe "ajean"). + par_id = {m.id: m for m in self.descriptor.models} + modele = model or self.descriptor.models[0].id + if modele not in par_id: + raise CredentialsManquantes(f"Modèle '{modele}' non déclaré par 'ajean_pc'.") + return self._build_compat('ajean_pc', par_id[modele], 'ajean') + + + class GeminiFreePlugin(ProviderPlugin): """Provider Gemini Free Tier (AI Studio) — étape 2 (#T231).""" @@ -968,7 +1022,7 @@ class AnthropicNativePlugin(ProviderPlugin): ), confidentiality='none', cascade_priority=3.4, - notes='API native avec accès aux Batchs. ANTHROPIC_API_KEY active — indépendante des quotas Claude Pro CLI. Curation étape 2 : Les 2 modèles du seed, câblés par le gateway. claude-fable-5 reste volontairement hors tiers (routing_policy.excluded_models, décision Axel 07/2026) — le descripteur ne change pas ce statut (routing_tier None).', + notes='API native. 13/08 Axel : crédit à sec → hors cascade (EXCLUSIONS_CASCADE_DEFAUT), clé conservée. claude-fable-5 déjà hors tiers (D-4). Accès explicite inchangé.', models=( ModelSpec( id='claude-sonnet-5', @@ -1303,39 +1357,23 @@ class OpenRouterPlugin(_PluginOpenAICompat): ), confidentiality='none', cascade_priority=2.4, - notes='Modèles gratuits et payants. Alternatives en cas de pannes de tiers. Curation étape 2 : Seulement les 2 modèles gratuits câblés par le gateway. Les 9 autres entrées du catalogue local sont offertes mais ni câblées ni appelées — leur activation éventuelle (fan-out maps gratuites, #T245) passera par un ajout explicite ici, pas par du stock dormant.', + notes='13/08 Axel : un seul modèle câblé, openrouter/auto. Les slugs Llama :free répondent 404 (unavailable for free). Les autres entrées catalogue restent offertes, non câblées.', models=( ModelSpec( - id='meta-llama/llama-3.3-70b-instruct:free', - display_name='Llama 3.3 70B Instruct (Free via OpenRouter)', - tier='free', + id='openrouter/auto', + display_name='OpenRouter Auto', + tier='paid', routing_tier='moyen', context_input=131072, context_output=4096, - cost_input_per_m=0.0, - cost_output_per_m=0.0, + cost_input_per_m=0.15, + cost_output_per_m=0.60, supports_tools=True, supports_json_mode=True, supports_streaming=True, speciality='raisonnement', - recommended_use='Raisonnement fort gratuit via OpenRouter', - notes='Plan Gratuit (OpenRouter)', - ), - ModelSpec( - id='meta-llama/llama-3.2-3b-instruct:free', - display_name='Llama 3.2 3B Instruct (Free via OpenRouter)', - tier='free', - routing_tier='leger', - context_input=131072, - context_output=4096, - cost_input_per_m=0.0, - cost_output_per_m=0.0, - supports_tools=True, - supports_json_mode=True, - supports_streaming=True, - speciality='ultra_rapide', - recommended_use='Tâches rapides gratuites via OpenRouter', - notes='Plan Gratuit (OpenRouter)', + recommended_use='Routeur OpenRouter — choisit un modèle vivant, plus de slug :free mort', + notes='Décision Axel 13/08. Auto facture le modèle routé (pas gratuit). Coûts catalogue = ordre de grandeur, solde OR = source d\'autorité.', ), ), ) @@ -1856,14 +1894,13 @@ class DashScopePlugin(_PluginOpenAICompat): ), confidentiality='training', cascade_priority=2.5, - notes="Coding Plan Lite (¥40/mois, stock) — clé sk-sp-*, endpoint coding-intl. Quota requêtes (pas tokens) : 1200/5h, 9000/sem, 18000/mois. CGU : usage prévu pour outils coding interactifs (pas backend automatisé). Curation étape 2 : L'allowlist exacte renvoyée par GET /v1/models le 26/07, tous câblés. ⚠️ CGU Alibaba : forfait prévu pour outils coding interactifs, usage backend automatisé hors périmètre officiel. C'est ce descripteur qui résout #T242 : la clé étant sur le Deck (#T244), l'enregistrement par plugin fait enfin arriver dashscope dans le catalogue de prod.", + notes="D-8 Axel 13/08 : désactivé (401 + CGU backend). Catalogue conservé, routing_tier retiré, gateway n'instancie plus. Clé DASHSCOPE_API_KEY gardée. Réactiver = DASHSCOPE_ACTIF=True dans llm_gateway.py.", models=( ModelSpec( id='dashscope/qwen3.7-plus', api_model_id='qwen3.7-plus', display_name='Qwen3.7 Plus (Coding Plan)', tier='subscription', - routing_tier='fort', context_input=1000000, context_output=65536, cost_input_per_m=0.0, @@ -1881,7 +1918,6 @@ class DashScopePlugin(_PluginOpenAICompat): api_model_id='qwen3.6-plus', display_name='Qwen3.6 Plus (Coding Plan)', tier='subscription', - routing_tier='fort', context_input=1000000, context_output=65536, cost_input_per_m=0.0, @@ -1899,7 +1935,6 @@ class DashScopePlugin(_PluginOpenAICompat): api_model_id='qwen3.5-plus', display_name='Qwen3.5 Plus (Coding Plan)', tier='subscription', - routing_tier='moyen', context_input=1000000, context_output=65536, cost_input_per_m=0.0, @@ -1917,7 +1952,6 @@ class DashScopePlugin(_PluginOpenAICompat): api_model_id='qwen3-max-2026-01-23', display_name='Qwen3 Max 2026-01-23 (Coding Plan)', tier='subscription', - routing_tier='fort', context_input=262144, context_output=65536, cost_input_per_m=0.0, @@ -1934,7 +1968,6 @@ class DashScopePlugin(_PluginOpenAICompat): api_model_id='qwen3-coder-next', display_name='Qwen3 Coder Next (Coding Plan)', tier='subscription', - routing_tier='moyen', context_input=262144, context_output=65536, cost_input_per_m=0.0, @@ -1951,7 +1984,6 @@ class DashScopePlugin(_PluginOpenAICompat): api_model_id='qwen3-coder-plus', display_name='Qwen3 Coder Plus (Coding Plan)', tier='subscription', - routing_tier='fort', context_input=1000000, context_output=65536, cost_input_per_m=0.0, @@ -1968,7 +2000,6 @@ class DashScopePlugin(_PluginOpenAICompat): api_model_id='kimi-k2.5', display_name='Kimi K2.5 (Coding Plan)', tier='subscription', - routing_tier='fort', context_input=262144, context_output=65536, cost_input_per_m=0.0, @@ -1986,7 +2017,6 @@ class DashScopePlugin(_PluginOpenAICompat): api_model_id='glm-5', display_name='GLM-5 (Coding Plan)', tier='subscription', - routing_tier='fort', context_input=202752, context_output=32768, cost_input_per_m=0.0, @@ -2003,7 +2033,6 @@ class DashScopePlugin(_PluginOpenAICompat): api_model_id='glm-4.7', display_name='GLM-4.7 (Coding Plan)', tier='subscription', - routing_tier='moyen', context_input=202752, context_output=32768, cost_input_per_m=0.0, @@ -2020,7 +2049,6 @@ class DashScopePlugin(_PluginOpenAICompat): api_model_id='MiniMax-M2.5', display_name='MiniMax M2.5 (Coding Plan)', tier='subscription', - routing_tier='moyen', context_input=196608, context_output=32768, cost_input_per_m=0.0, @@ -2062,6 +2090,7 @@ def build(self, credentials, model=None): MistralPlugin(), LMStudioLocalPlugin(), OllamaLocalPlugin(), + AjeanPlugin(), GeminiFreePlugin(), GeminiPaidPlugin(), GeminiCLIPluginInterne(), diff --git a/core/llm/circuit_breaker.py b/core/llm/circuit_breaker.py index a3ca6f4..5f8c861 100644 --- a/core/llm/circuit_breaker.py +++ b/core/llm/circuit_breaker.py @@ -45,6 +45,11 @@ class CircuitBreaker: _instances = _registry _global_lock = _registry_lock + # [#T352] Délai de réouverture maximal (30 min). Au-delà, le service est + # durablement indisponible : on ne pousse pas plus loin pour ne pas rendre un + # service rétabli inaccessible trop longtemps (voir PR #T352 pour la justification). + DEFAULT_MAX_RECOVERY_TIMEOUT: float = 1800.0 + @classmethod def get_or_create(cls, name: str, failure_threshold: int = 3, recovery_timeout: float = 30.0) -> "CircuitBreaker": """Récupère ou crée une instance de disjoncteur pour un modèle donné (thread-safe).""" @@ -58,10 +63,25 @@ def __init__( name: str, failure_threshold: int = 3, recovery_timeout: float = 30.0, + max_recovery_timeout: float | None = None, ): self.name = name self.failure_threshold = failure_threshold + + # [#T352] `recovery_timeout` reste le délai EFFECTIF courant (rétro-compatible : + # première ouverture = valeur de base passée par l'appelant). `_base_recovery_timeout` + # conserve la valeur d'origine pour recalculer la progression et se remettre à zéro + # après un succès en HALF_OPEN. + self._base_recovery_timeout = recovery_timeout self.recovery_timeout = recovery_timeout + self._max_recovery_timeout = ( + max_recovery_timeout if max_recovery_timeout is not None else self.DEFAULT_MAX_RECOVERY_TIMEOUT + ) + + # [#T352] Nombre d'ouvertures CONSÉCUTIVES sans succès entre-temps. Chaque nouvelle + # ouverture double le délai de réouverture (30 s, 1 min, 2 min…) jusqu'au plafond. + # Un succès en HALF_OPEN remet ce compteur à zéro. + self._consecutive_trips = 0 self._state = CircuitBreakerState.CLOSED self._failure_count = 0 @@ -115,6 +135,10 @@ def record_success(self, latency: float = 0.0) -> None: logger.info(f"[CIRCUIT BREAKER] {self.name} refermé (CLOSED) suite à un succès en HALF_OPEN") self._state = CircuitBreakerState.CLOSED self._failure_count = 0 + # [#T352] Un succès en HALF_OPEN prouve que le service est rétabli : + # on remet le délai de réouverture à sa valeur de base. + self._consecutive_trips = 0 + self.recovery_timeout = self._base_recovery_timeout self._last_state_change = time.time() elif self._state == CircuitBreakerState.CLOSED: self._failure_count = 0 @@ -143,13 +167,37 @@ def record_rate_limit(self) -> None: logger.warning(f"[CIRCUIT BREAKER] Rate limit (429) détecté sur {self.name}. Disjoncteur déclenché.") self._trip() + def _compute_recovery_timeout(self) -> float: + """Délai de réouverture courant selon le nombre d'ouvertures consécutives. + + [#T352] Progression géométrique : première ouverture = valeur de base, + puis double à chaque nouvelle ouverture consécutive, plafonnée à + `_max_recovery_timeout`. Un service durablement absent voit donc son délai + croître au lieu de payer un timeout fixe toutes les 30 secondes. + """ + if self._consecutive_trips <= 1: + return self._base_recovery_timeout + exponent = self._consecutive_trips - 1 + return min( + self._base_recovery_timeout * (2 ** exponent), + self._max_recovery_timeout, + ) + def _trip(self) -> None: - """Ouvre le disjoncteur (transition vers OPEN).""" + """Ouvre le disjoncteur (transition vers OPEN). + + [#T352] À chaque nouvelle ouverture consécutive, le délai de réouverture + augmente (30 s, 1 min, 2 min…) jusqu'au plafond. Le délai retenu est + journalisé à chaque ouverture pour pouvoir vérifier le mécanisme en prod. + """ + self._consecutive_trips += 1 + self.recovery_timeout = self._compute_recovery_timeout() self._state = CircuitBreakerState.OPEN self._last_state_change = time.time() self.total_trips += 1 logger.error( - f"[CIRCUIT BREAKER] 🚨 {self.name} a DISJONCTÉ (état OPEN) pour {self.recovery_timeout}s. " + f"[CIRCUIT BREAKER] 🚨 {self.name} a DISJONCTÉ (état OPEN) pour {self.recovery_timeout:.0f}s " + f"(ouverture consécutive n°{self._consecutive_trips}). " f"Seuil d'échecs ({self.failure_threshold}) dépassé." ) @@ -180,6 +228,9 @@ def get_stats(self) -> dict[str, Any]: "failure_count": self._failure_count, "failure_threshold": self.failure_threshold, "recovery_timeout": self.recovery_timeout, + "base_recovery_timeout": self._base_recovery_timeout, + "max_recovery_timeout": self._max_recovery_timeout, + "consecutive_trips": self._consecutive_trips, "time_since_last_change": round(time.time() - self._last_state_change, 1), "total_calls": self.total_calls, "total_failures": self.total_failures, diff --git a/core/llm/provider_health.py b/core/llm/provider_health.py new file mode 100644 index 0000000..c903b2c --- /dev/null +++ b/core/llm/provider_health.py @@ -0,0 +1,164 @@ +""" +core/llm/provider_health.py — Sonde d'accessibilité d'hôte (fast-path). + +Répond à « la machine décroche-t-elle ? » par une ouverture TCP seule, sans +requête HTTP ni inférence. C'est le maillon qui manquait à #T352 : le +disjoncteur (core/llm/circuit_breaker.py) agit après trois échecs, la sonde +agit AVANT le premier. + +Journal de la PRODUCTION, 17/08 (après le déploiement de #T352) : + 16:49:57,020 [FAST_PATH] Tentative → ollama_pc + 16:49:57,022 [FALLBACK GATEWAY] Tentative avec le modèle : ollama_pc (CB: CLOSED) + 16:49:59,027 [CIRCUIT BREAKER] Échec détecté sur ollama_pc (1/3) : + ConnectTimeoutError(host='192.168.1.10', port=11434, connect timeout=2.0) + 16:49:59,028 [FALLBACK GATEWAY] Échec du modèle ollama_pc […] Backoff 0.6s avant le suivant… + 16:49:59,581 [FAST_PATH] ollama_pc échoué + +Soit 2,56 s perdues avant bascule, sur une machine (192.168.1.10) simplement +éteinte. #T352 n'y suffit pas : le disjoncteur n'ouvre qu'après trois échecs +(seuil 3), et le délai de réouverture progressif ne s'applique qu'APRÈS cette +ouverture — le provider est donc réessayé au prix fort à chaque phrase tant que +le PC reste éteint. La sonde, elle, écarte l'hôte muet en ~0,3 s, dès le premier +essai. + +Cache en mémoire du processus, par (hôte, port) : +- succès mémorisé TTL_SUCCES_S (300 s) : on ne ressonde pas un hôte qui répond ; +- échec mémorisé TTL_ECHEC_S (60 s) : le TTL court permet au PC d'être repris + automatiquement dès qu'il se rallume. + +Une seule sonde en vol par hôte (verrou par clé) : dix requêtes simultanées ne +lancent pas dix sondes. +""" + +import asyncio +import logging +import time +from urllib.parse import urlparse + +logger = logging.getLogger(__name__) + +# ── Constantes nommées (pas de nombres nus dans le code) ──────────────────── +# TTL de mémorisation d'un SUCCÈS de sonde (hôte qui décroche), en secondes. +TTL_SUCCES_S: float = 300.0 +# TTL de mémorisation d'un ÉCHEC de sonde (hôte muet), en secondes. Court pour +# que le PC soit repris automatiquement dès qu'il se rallume. +TTL_ECHEC_S: float = 60.0 +# Délai d'ouverture TCP de la sonde : on ne veut pas payer un connect timeout de +# 2 s (celui du provider) à chaque phrase — 0,3 s suffit à dire « ça décroche ». +TIMEOUT_SONDE_S: float = 0.3 + +# Cache en mémoire : (hôte, port) -> (expiration monotonic, booléen joignable). +_cache: dict[tuple[str, int], tuple[float, bool]] = {} +# Verrous par clé : une seule sonde en vol par hôte. +_verrous: dict[tuple[str, int], asyncio.Lock] = {} + + +def hote_et_port(base_url: str) -> tuple[str, int] | None: + """Extrait (hôte, port) d'une base_url, ou None si illisible. + + None (hôte absent, port absent ou invalide) = « on ne sait pas » : l'appelant + doit alors tenter le provider comme avant, jamais l'écarter sur un doute. + """ + try: + parsed = urlparse(base_url) + hote = parsed.hostname + port = parsed.port + if not hote or not port: + return None + return (hote, port) + except Exception: + return None + + +def base_url_provider(provider) -> str | None: + """Extrait le base_url d'un provider en déballant wrappers et cascades. + + Déballe récursivement un `FallbackProvider` (on lit son premier candidat) et + les wrappers décorateurs exposant `.provider` (ex. ClaudeInstructionsWrapper) + jusqu'à atteindre un provider portant un `base_url` en chaîne. Retourne None + si aucun base_url exploitable (doute → on tente le provider comme avant). + + Le déballage est générique (duck-typing) pour ne dépendre d'aucune classe + concrète lourde et ne pas créer de cycle d'import. + """ + vus: set[int] = set() + while provider is not None and id(provider) not in vus: + vus.add(id(provider)) + # FallbackProvider : cascade de candidats, on lit le premier. + sous = getattr(provider, "providers", None) + if isinstance(sous, list) and sous: + provider = sous[0][1] + continue + base_url = getattr(provider, "base_url", None) + if isinstance(base_url, str): + return base_url + # Wrapper décorateur exposant un provider interne porteur du base_url. + interne = getattr(provider, "provider", None) + if interne is not None and hasattr(interne, "base_url"): + provider = interne + continue + return None + return None + + +async def _sonder(host: str, port: int, timeout: float) -> bool: + """Ouvre une connexion TCP vers (host, port) et la referme immédiatement. + + Aucune requête HTTP, aucune inférence : on répond seulement à « la machine + décroche-t-elle ? ». Toute exception (socket, DNS, boucle asyncio, timeout) + remonte — `hote_joignable` la convertit en « on ne sait pas ». + """ + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host, port), + timeout=timeout, + ) + writer.close() + try: + await writer.wait_closed() + except Exception as _e: + # La fermeture peut échouer (socket déjà fermé côté pair) : sans + # conséquence pour la sonde, on l'ignore après l'avoir journalisée. + logger.debug(f"[PROVIDER_HEALTH] Fermeture de la sonde {host}:{port} : {_e}") + return True + + +async def hote_joignable(base_url: str, timeout: float = TIMEOUT_SONDE_S) -> bool: + """True si l'hôte de base_url « décroche » (port TCP joignable), False sinon. + + Mémorise le résultat par (hôte, port) avec un TTL différent selon l'issue : + succès 300 s, échec 60 s (constantes nommées). Une seule sonde en vol par + hôte : les appels simultanés attendent le même verrou puis relisent le cache. + + Toute exception de la sonde (socket, DNS, boucle asyncio, timeout) est + convertie en False — « on ne sait pas » — jamais en refus bloquant. + """ + cle = hote_et_port(base_url) + if cle is None: + # Hôte/port illisibles : doute → on considère l'hôte joignable pour ne + # jamais écarter un provider sur une information qu'on ne peut pas lire. + return True + + maintenant = time.monotonic() + cache = _cache.get(cle) + if cache is not None and cache[0] > maintenant: + return cache[1] + + verrou = _verrous.get(cle) + if verrou is None: + verrou = asyncio.Lock() + _verrous[cle] = verrou + + async with verrou: + # Re-vérification après acquisition : la sonde a pu être faite entre-temps + # par une autre coroutine (verrou par clé). + cache = _cache.get(cle) + if cache is not None and cache[0] > time.monotonic(): + return cache[1] + try: + resultat = await _sonder(cle[0], cle[1], timeout) + except Exception as _e: + logger.debug(f"[PROVIDER_HEALTH] Sonde {cle[0]}:{cle[1]} en échec : {_e}") + resultat = False + ttl = TTL_SUCCES_S if resultat else TTL_ECHEC_S + _cache[cle] = (time.monotonic() + ttl, resultat) + return resultat diff --git a/core/llm/providers/deepseek.py b/core/llm/providers/deepseek.py index 9a04e48..b82278f 100644 --- a/core/llm/providers/deepseek.py +++ b/core/llm/providers/deepseek.py @@ -185,7 +185,7 @@ async def generate_structured_async( class LMStudioProvider(LLMProvider): """Provider pour l'exécution locale (Garantie de confidentialité, coût 0).""" - def __init__(self, base_url: str = "http://${LM_STUDIO_HOST:-192.168.1.x}:1234/v1/chat/completions"): + def __init__(self, base_url: str = "http://192.168.1.10:1234/v1/chat/completions"): self.base_url = base_url self.headers = {"Content-Type": "application/json"} @@ -259,11 +259,11 @@ class OllamaDeckProvider(LLMProvider): Compatible API OpenAI (format identique à LMStudioProvider). """ - DECK_HOSTS = ["${DECK_HOST:-192.168.1.x}", "${DECK_HOST_WIFI:-192.168.1.x}"] # Ethernet prioritaire, Wi-Fi fallback + DECK_HOSTS = ["192.168.1.20", "192.168.1.21"] # Ethernet exemple, Wi-Fi fallback def __init__( self, - host: str = "${DECK_HOST:-192.168.1.x}", + host: str = "192.168.1.20", port: int = 11434, model_name: str = "phi3:mini", ): diff --git a/core/llm_gateway.py b/core/llm_gateway.py index 36e55a9..5ef0e80 100644 --- a/core/llm_gateway.py +++ b/core/llm_gateway.py @@ -13,11 +13,13 @@ key_pool, semantic_cache...). """ import copy +import ipaddress import json import logging import os import threading from typing import Any +from urllib.parse import urlparse logger = logging.getLogger(__name__) @@ -41,6 +43,51 @@ ) from core.llm.providers.gemini import GeminiCLIProvider, GeminiProvider +# Décision Axel 13/08 : hors cascade auto, accès explicite `get_provider()` inchangé. +# - slugs OpenRouter :free → 404 mesuré +# - Claude API native → crédit à sec (HTTP 400) +# - claude-fable-5 → déjà D-4 (anti auto-escalade) +# - Dashscope → 401 + CGU backend (D-8) : préfixe filtré aussi (voir _est_exclu_cascade) +EXCLUSIONS_CASCADE_DEFAUT = frozenset({ + "claude-fable-5", + "claude-sonnet-5", + "claude-opus-4-8-direct", + "claude-haiku-4-5-direct", + "meta-llama/llama-3.3-70b-instruct:free", + "meta-llama/llama-3.2-3b-instruct:free", + "dashscope", + "dashscope/qwen3.7-plus", + "dashscope/qwen3.6-plus", + "dashscope/qwen3.5-plus", + "dashscope/qwen3-max-2026-01-23", + "dashscope/qwen3-coder-next", + "dashscope/qwen3-coder-plus", + "dashscope/kimi-k2.5", + "dashscope/glm-5", + "dashscope/glm-4.7", + "dashscope/MiniMax-M2.5", +}) + +# D-8 Axel 13/08 : clé 401 + CGU Alibaba (pas de backend auto). Ne pas instancier. +DASHSCOPE_ACTIF = False + +# [T337] Niveau de confidentialité lu depuis l'environnement. +# - vide (défaut) : comportement historique inchangé au bit près. +# - "local_only" : AUCUNE requête ne part vers un provider cloud — seuls +# les modèles servis par un provider LOCAL sont retenus, et +# si aucun n'est disponible le tier ÉCHOUE (fail-closed), +# jamais de repli silencieux vers le cloud. +# Surchargeable par appel via le paramètre `privacy_level` de get_provider_for_tier(). +MOTEUR_PRIVACY_LEVEL_ENV = "MOTEUR_PRIVACY_LEVEL" +MOTEUR_PRIVACY_LOCAL_ONLY = "local_only" + + +def _est_exclu_cascade(model_id: str, excluded: set[str]) -> bool: + """Exclusion exacte + préfixe dashscope/ (catalogue Deck peut encore avoir un routing_tier).""" + if model_id in excluded: + return True + return model_id == "dashscope" or model_id.startswith("dashscope/") + # Import du provider natif Gemini (caching + grounding) try: from core.gemini_native import GeminiNativeProvider @@ -171,7 +218,14 @@ def __init__(self, deepseek_key: str = None, gemini_key: str = None): os.environ.get("DASHSCOPE_API_KEY") or os.environ.get("BAILIAN_CODING_PLAN_API_KEY") ) - if not dashscope_key: + if not DASHSCOPE_ACTIF: + if dashscope_key: + logger.info( + "[LLMGateway] Dashscope désactivé (D-8 Axel 13/08) — " + "clé présente mais providers non instanciés (401 + CGU backend)." + ) + dashscope_key = None + elif not dashscope_key: logger.info( f"[LLMGateway] {missing_key_message('DASHSCOPE_API_KEY')} — " "le Coding Plan Alibaba (DashScope) sera désactivé. " @@ -191,7 +245,7 @@ def __init__(self, deepseek_key: str = None, gemini_key: str = None): # Instanciation des 9 providers OpenAI-compatibles via la factory # Remplace ~1500 lignes de classes dupliquées par des appels config-driven - from core.openai_compat_provider import OPENAI_COMPAT_PROVIDERS, OpenAICompatibleProvider + from core.openai_compat_provider import AJEAN_DEFAULT_MODEL, OPENAI_COMPAT_PROVIDERS, OpenAICompatibleProvider def _make_compat(provider_id: str, model: str, api_key: str, timeout: tuple = None) -> OpenAICompatibleProvider: """Crée un provider OpenAI-compatible à partir du registre centralisé.""" @@ -268,12 +322,14 @@ def _make_compat(provider_id: str, model: str, api_key: str, timeout: tuple = No }) # --- OpenRouter --- + # 13/08 : les slugs :free Llama sont morts (HTTP 404 « unavailable for + # free »). Décision Axel : un seul modèle câblé, `openrouter/auto`. _openrouter = {} if openrouter_key: + _or_auto = _make_compat("openrouter", "openrouter/auto", openrouter_key) _openrouter = { - "openrouter": _make_compat("openrouter", "meta-llama/llama-3.3-70b-instruct:free", openrouter_key), - "meta-llama/llama-3.3-70b-instruct:free": _make_compat("openrouter", "meta-llama/llama-3.3-70b-instruct:free", openrouter_key), - "meta-llama/llama-3.2-3b-instruct:free": _make_compat("openrouter", "meta-llama/llama-3.2-3b-instruct:free", openrouter_key), + "openrouter": _or_auto, + "openrouter/auto": _or_auto, } # --- xAI (Grok) --- @@ -424,7 +480,7 @@ def _make_anthropic(model: str) -> AnthropicNativeProvider: "domotique-qwen7b:q4": _make_compat("ollama_local", "domotique-qwen7b:q4", "ollama"), "qwen2.5-coder:7b": _make_compat("ollama_local", "qwen2.5-coder:7b", "ollama"), "deepseek-r1:8b": _make_compat("ollama_local", "deepseek-r1:8b", "ollama"), - # Variante joignable en LAN (${LM_STUDIO_HOST:-192.168.1.x}) depuis le Deck — cf. commentaire + # Variante joignable en LAN (192.168.1.10) depuis le Deck — cf. commentaire # dans OPENAI_COMPAT_PROVIDERS["ollama_pc"]. Utilisée par le fast path vocal # (FAST_PATH_PROVIDERS) pour du local-first même quand le moteur tourne sur le Deck. # Timeout dédié (connect 2s, read 15s) — PAS la famille "lmstudio" (120s de read) : @@ -434,6 +490,19 @@ def _make_anthropic(model: str) -> AnthropicNativeProvider: # de la marge pour basculer sur le cloud dans le budget, plutôt que de faire # attendre l'utilisateur ~30-90s en silence sur un cold start. "ollama_pc": _make_compat("ollama_pc", "domotique-qwen7b:q4", "ollama", timeout=(2.0, 15.0)), + + # === AJEAN (llama.cpp / llama-server, port 8080) — déclaratif, NON branché === + # Deux hôtes symétriques : le PC local (192.168.1.10) et le Deck (127.0.0.1). + # Déclarés par IP, jamais par nom DNS, pour rester dans la famille locale + # (#T337, `_est_hote_local`). Modèle par défaut = AJEAN_DEFAULT_MODEL (le + # réellement chargé). Timeout de connexion COURT (2s), comme ollama_pc : un + # hôte local éteint doit échouer vite et basculer sur le cloud. À ce jour + # llama-server écoute encore --host 127.0.0.1 : ajean_pc est donc injoignable + # depuis le Deck tant qu'Axel n'a pas basculé sur 0.0.0.0 + pare-feu ouvert. + # Pas encore mis en tête de cascade (FAST_PATH_PROVIDERS intact) : l'ouverture + # réseau est une décision d'exploitation en attente. + "ajean_pc": _make_compat("ajean_pc", AJEAN_DEFAULT_MODEL, "ajean", timeout=(2.0, 15.0)), + "ajean_deck": _make_compat("ajean_deck", AJEAN_DEFAULT_MODEL, "ajean", timeout=(2.0, 15.0)), } self.providers: dict[str, LLMProvider] = { @@ -452,7 +521,7 @@ def _make_anthropic(model: str) -> AnthropicNativeProvider: "local": LMStudioProvider(), # === STEAM DECK EDGE AI (Ollama RDNA2) === - # Endpoint réseau local : http://${DECK_HOST:-192.168.1.x}:11434 + # Endpoint réseau local : http://192.168.1.10:11434 # Disponibilité vérifiée dynamiquement via ping_available() # Tiers recommandés : parsing_logs, yaml_format, resume_court "deck_ollama": OllamaDeckProvider(), # phi3:mini par défaut @@ -688,9 +757,12 @@ def get_provider(self, name: str) -> LLMProvider: def stream(self, provider_name: str, system_prompt: str, user_prompt: str, **kwargs): """ Streaming token-par-token via le provider spécifié. - + Yields: - dict: {"token": str, "done": bool, "usage": dict|None} + dict: {"token": str, "done": bool, "usage": dict|None}, ou + {"reasoning": str, "done": False, "usage": None} quand le modèle + émet son raisonnement (#T346) — les chunks passent tels quels, le + gateway ne mélange JAMAIS le raisonnement avec les tokens. """ provider = self.get_provider(provider_name) yield from provider.generate_stream(system_prompt, user_prompt, **kwargs) @@ -739,24 +811,64 @@ def _resolve_tier_models(self, tier: str, config: dict) -> tuple[str, list]: allowed_models = default_map.get(actual_tier, ["gemini-3.5-flash"]) # [routing_policy] Exclusions volontaires du routage automatique, quelle - # que soit la source (config.json OU models_registry.db). Cas d'usage : - # claude-fable-5 est délibérément hors tiers (décision Axel 07/2026, - # anti auto-escalade vers le modèle le plus cher) — ce filtre garantit - # qu'un ajout ultérieur en DB (routing_tier) ne le réintroduira pas. - # L'accès EXPLICITE via get_provider("claude-fable-5") reste possible. + # que soit la source (config.json OU models_registry.db). Fusionné avec + # EXCLUSIONS_CASCADE_DEFAUT : le config.json du Deck est préservé au + # deploy, le défaut code doit donc suffire. Accès explicite inchangé. excluded = set(config.get("routing_policy", {}).get("excluded_models", [])) - if excluded: - filtered = [m for m in allowed_models if m not in excluded] - if len(filtered) != len(allowed_models): - logger.info( - f"[LLMGateway] routing_policy : modèle(s) exclu(s) du tier '{actual_tier}' : " - f"{sorted(set(allowed_models) - set(filtered))}" - ) - allowed_models = filtered + excluded |= EXCLUSIONS_CASCADE_DEFAUT + filtered = [m for m in allowed_models if not _est_exclu_cascade(m, excluded)] + if len(filtered) != len(allowed_models): + logger.info( + f"[LLMGateway] routing_policy : modèle(s) exclu(s) du tier '{actual_tier}' : " + f"{sorted(set(allowed_models) - set(filtered))}" + ) + allowed_models = filtered return actual_tier, allowed_models - def get_provider_for_tier(self, tier: str, config: dict, elo_order: list = None) -> tuple[str, FallbackProvider]: + # [T337] Famille « locale » : un provider est local si son endpoint (`base_url`) + # pointe vers un hôte LOOPBACK ou une IP privée LAN (RFC 1918). C'est un critère + # INTRINSÈQUE au provider, pas une liste de noms de modèles recopiée à la main : + # un futur modèle Ollama ajouté au catalogue est automatiquement local car son + # `base_url` reste local, sans aucune retouche ici. Les providers cloud, eux, ont + # tous un `base_url` https:// vers un domaine public → jamais classés locaux. + @staticmethod + def _est_hote_local(host: str) -> bool: + """True si l'hôte d'un endpoint est loopback ou une IP privée LAN.""" + if not host: + return False + host_lower = host.lower().strip("[]") + if host_lower in ("localhost", "::1"): + return True + try: + ip = ipaddress.ip_address(host_lower) + except ValueError: + # Hôte non-IP (nom DNS) : considéré comme externe / non-local. + return False + return ip.is_loopback or ip.is_private + + def _is_local_provider(self, name: str) -> bool: + """ + Détermine si le provider enregistré sous `name` appartient à la famille locale, + en inspectant le `base_url` du provider BRUT (sous son wrapper éventuel). + """ + provider = self.providers.get(name.lower()) + if provider is None: + return False + # Déballer le wrapper ClaudeInstructionsWrapper pour atteindre le provider réel. + if isinstance(provider, ClaudeInstructionsWrapper): + provider = provider.provider + base_url = getattr(provider, "base_url", None) + if not base_url: + return False + try: + host = urlparse(base_url).hostname + except Exception: + return False + return self._est_hote_local(host) + + def get_provider_for_tier(self, tier: str, config: dict, elo_order: list = None, + privacy_level: str | None = None) -> tuple[str, FallbackProvider]: """ Résout un Tier en un FallbackProvider contenant les modèles configurés pour ce Tier, triés dynamiquement. @@ -768,7 +880,26 @@ def get_provider_for_tier(self, tier: str, config: dict, elo_order: list = None) [T133] Le scoring coût/quota/latence est délégué à ProviderScorer (core/provider_scorer.py, ex God Object de 150+ lignes) — cette méthode ne fait plus que résoudre le tier, instancier les providers et trier. + + [T337] Mode confidentialité « local_only » (FAIL-CLOSED) : + `privacy_level` (optionnel, défaut None) force le niveau de confidentialité + par appel SANS toucher les appelants existants : + - None (défaut) → on lit la variable d'environnement MOTEUR_PRIVACY_LEVEL. + - "local_only" → ne retient QUE les modèles servis par un provider LOCAL + (famille locale déterminée par l'endpoint, cf. + `_is_local_provider`). Si AUCUN modèle local n'est + disponible dans le tier, on LÈVE une erreur explicite + journalisée : le moteur NE retombe JAMAIS sur un provider + cloud « pour que ça marche quand même ». Un mode + confidentialité qui se dégrade en silence est pire que pas + de mode du tout. + - toute autre valeur → comportement historique inchangé. """ + # [T337] Priorité : paramètre d'appel explicite > variable d'environnement. + if privacy_level is None: + privacy_level = os.environ.get(MOTEUR_PRIVACY_LEVEL_ENV, "") + local_only = (privacy_level == MOTEUR_PRIVACY_LOCAL_ONLY) + actual_tier, allowed_models = self._resolve_tier_models(tier, config) providers_list = [] @@ -777,10 +908,17 @@ def get_provider_for_tier(self, tier: str, config: dict, elo_order: list = None) # [#T212] Résolution brute : le FallbackProvider construit ci-dessous # applique déjà le Circuit Breaker sur chaque modèle de la liste. provider = self._get_raw_provider(model) - providers_list.append((model, provider)) except ValueError: logger.warning(f"Modèle {model} non disponible ou clé API manquante.") continue + # [T337] En mode local_only, on écarte tout modèle servi par un provider CLOUD. + if local_only and not self._is_local_provider(model): + logger.info( + f"[LLMGateway] privacy_level=local_only : modèle cloud écarté du tier " + f"'{tier}' : {model}" + ) + continue + providers_list.append((model, provider)) from core.provider_scorer import ProviderScorer scorer = ProviderScorer([m for m, _ in providers_list]) @@ -789,6 +927,15 @@ def get_provider_for_tier(self, tier: str, config: dict, elo_order: list = None) logger.info(f"[LLMGateway] Tier '{tier}' (actual: '{actual_tier}') -> allowed_models: {allowed_models} -> trié (prioritaire d'abord): {[p[0] for p in providers_list]}") if not providers_list: + # [T337] FAIL-CLOSED : en mode local_only, un tier sans modèle local est une + # erreur EXPLICITE — jamais de repli silencieux vers le cloud. L'utilisateur + # croirait sa donnée locale alors qu'elle partirait chez un tiers. + if local_only: + raise ValueError( + f"privacy_level=local_only : aucun modèle local disponible dans le tier " + f"'{tier}' (actual: '{actual_tier}'). Modèles candidats: {allowed_models}. " + f"Aucune requête ne part vers un provider cloud." + ) for fallback_model in ["gemini-3.5-flash", "deepseek-chat", "local"]: try: providers_list.append((fallback_model, self._get_raw_provider(fallback_model))) @@ -969,7 +1116,7 @@ def load_config(force_reload: bool = False) -> dict: # [#T202] Clone Git dédié aux tâches DreamCoder (branches task/*). # Vide = repli sur le dossier du moteur (poste de dev). Sur le Deck # (prod overlay sans dépôt légitime), pointer le clone dédié, ex: - # /home/deck/dev_station/moteur_agents_dreamcoder + # /opt/vromvrom-engine_dreamcoder "dreamcoder_repo_path": "", } } diff --git a/core/mcp_bridge.py b/core/mcp_bridge.py index 0771e4e..ec15ca7 100644 --- a/core/mcp_bridge.py +++ b/core/mcp_bridge.py @@ -134,8 +134,8 @@ async def start(self, registry: ToolRegistry, user_prompt: str = "") -> None: for arg in args: if isinstance(arg, str): # Remplacer les chemins réseau Windows par le chemin Linux local - arg_adapted = arg.replace("\\\\192.168.1.x\\config", "/config") - arg_adapted = arg_adapted.replace("e:\\AuxFilsDesIdees", "/config") + arg_adapted = arg.replace("\\\\192.168.1.10\\config", "/config") + arg_adapted = arg_adapted.replace("/path/to/workspace", "/config") # Remplacer les antislashs par des slashs arg_adapted = arg_adapted.replace("\\", "/") new_args.append(arg_adapted) diff --git a/core/mcp_tools/orchestrator.py b/core/mcp_tools/orchestrator.py index 4f9cbc9..a9944ef 100644 --- a/core/mcp_tools/orchestrator.py +++ b/core/mcp_tools/orchestrator.py @@ -611,7 +611,7 @@ async def query_llm_direct( - "deepseek-chat" : $0.14/$0.28 par M tokens — ultra économique - "deepseek-reasoner" : R1, $0.55/$2.19 — Chain of Thought - "deepseek-v4-pro" : $0.435/$0.87 — raisonnement avancé - - "local" : LM Studio RTX 5070Ti (0$, 50 tok/s, confidentiel) + - "local" : LM Studio GPU locale (0$, 50 tok/s, confidentiel) - "claude-sonnet-4-6" : Inclus Claude Pro, pair programming - "claude-opus-4-8" : Inclus Claude Pro, architecture - "gemini-2.5-flash" : Free Tier (0$, 80 tok/s, raisonnement) diff --git a/core/ml_router.py b/core/ml_router.py index b97064e..be13b76 100644 --- a/core/ml_router.py +++ b/core/ml_router.py @@ -367,19 +367,19 @@ async def _detect_provider_async(self) -> None: local_targets = [ { "type": "ollama", - "url": "http://192.168.1.x:11434/api/embeddings", + "url": "http://192.168.1.10:11434/api/embeddings", "payload": {"model": "nomic-embed-text", "prompt": "ping"}, "model": "nomic-embed-text" }, { "type": "ollama", - "url": "http://192.168.1.x:11434/api/embeddings", + "url": "http://192.168.1.10:11434/api/embeddings", "payload": {"model": "nomic-embed-text", "prompt": "ping"}, "model": "nomic-embed-text" }, { "type": "lmstudio", - "url": "http://192.168.1.x:1234/v1/embeddings", + "url": "http://192.168.1.10:1234/v1/embeddings", "payload": {"model": "nomic-embed-text", "input": ["ping"]}, "model": "nomic-embed-text" } @@ -444,19 +444,19 @@ def _detect_provider_sync(self) -> None: local_targets = [ { "type": "ollama", - "url": "http://192.168.1.x:11434/api/embeddings", + "url": "http://192.168.1.10:11434/api/embeddings", "payload": {"model": "nomic-embed-text", "prompt": "ping"}, "model": "nomic-embed-text" }, { "type": "ollama", - "url": "http://192.168.1.x:11434/api/embeddings", + "url": "http://192.168.1.10:11434/api/embeddings", "payload": {"model": "nomic-embed-text", "prompt": "ping"}, "model": "nomic-embed-text" }, { "type": "lmstudio", - "url": "http://192.168.1.x:1234/v1/embeddings", + "url": "http://192.168.1.10:1234/v1/embeddings", "payload": {"model": "nomic-embed-text", "input": ["ping"]}, "model": "nomic-embed-text" } diff --git a/core/model_health_probe.py b/core/model_health_probe.py index 5103b7f..f5e7827 100644 --- a/core/model_health_probe.py +++ b/core/model_health_probe.py @@ -25,6 +25,26 @@ modèles) ; 3. ne jamais descendre sous un plancher de modèles actifs ; 4. concurrence bornée et prompt minuscule — la sonde consomme de vrais tokens. + +Ce que cette sonde s'est vu ajouter (mesuré en prod : 31/76 modèles éteints, +112 cycles consécutifs de re-confirmation de la même panne, ~3 s par test) : + +- **Backoff sur ce qui est déjà éteint.** Un modèle éteint par la sonde n'est + plus re-testé à chaque cycle horaire : l'intervalle de re-test croît avec le + nombre d'échecs consécutifs (`intervalle_backoff`), plafonné à une journée. + Une panne actionnable (crédit épuisé, workspace non trusté, binaire absent, + clé refusée) ne se répare pas par la répétition : on la re-confirme de moins + en moins souvent, sans jamais cesser de la re-tester — la réactivation reste + automatique le jour où Axel répare la cause. +- **Classement de la cause.** `classer_erreur` distingue « actionnable par un + humain » d'« inconnue / transitoire » à partir du texte de la dernière erreur. + Prudence : on ne classe comme actionnable que ce qu'on reconnaît de façon + fiable ; dans le doute, c'est transitoire (un modèle sain rangé par erreur + dans « actionnable » serait mis au placard pour la journée). +- **Visible une fois, pas cent.** Une cause actionnable produit UN message + clair et actionnable (quoi faire, sur quelle machine) quand elle est détectée + ou quand elle change — jamais à chaque cycle (colonne + `cause_actionnable_signalee`). """ from __future__ import annotations @@ -50,6 +70,57 @@ # Appels simultanés : borné pour ne pas faire une rafale sur les APIs. CONCURRENCE = 4 TIMEOUT_PING_S = 20.0 +# Plafond du backoff de re-test d'un modèle déjà éteint par la sonde : une +# journée. Au-delà, on re-testé au plus une fois par jour — assez pour +# réactiver le jour même où la cause disparaît, sans re-confirmer la même panne +# 24 fois par jour. +PLAFOND_BACKOFF_S = 24 * 3600 +# Colonne de suivi d'alerte : mémorise la catégorie actionnable déjà signalée +# pour un modèle, pour ne pas répéter le même message à chaque cycle. +_COLONNE_ALERTE = "cause_actionnable_signalee" + +# Motifs actionnables reconnus de façon fiable, mesurés en prod le 17/08. +# Chaque entrée : (catégorie, tuple de fragments à chercher, message humain). +# Règle de prudence : on n'ajoute ici QUE ce qu'on a vu échouer en réel, pas +# une taxonomie théorique — un faux positif mettrait un modèle sain au placard. +_PATTERNS_ACTIONNABLES: tuple[tuple[str, tuple[str, ...], str], ...] = ( + ( + "credit", + # HTTP 400 Anthropic (reproduit en direct) : solde trop bas. + ("credit balance is too low", "insufficient_quota", "out of quota", + "insufficient balance", "no credit", "solde insuffisant"), + "Recharge le crédit du compte (Plans & Billing sur api.anthropic.com) — " + "aucune nouvelle tentative ne réparera ça.", + ), + ( + "trust", + # Claude CLI : workspace non trusté, à accepter une fois en interactif. + ("has not been trusted", "hastrustdialogaccepted", "trust dialog"), + "Accepte le dialogue de confiance de Claude Code une fois en interactif " + "sur la machine concernée (ou pose hasTrustDialogAccepted: true dans sa " + "configuration).", + ), + ( + "binaire", + # Binaire CLI absent (Gemini *-cli) : erreur système de fichier. + ("no such file or directory", "[errno 2]", "not recognized as an " + "internal or external command"), + "Installe ou corrige le chemin du binaire CLI manquant sur la machine " + "qui héberge le provider.", + ), + ( + "cle", + # Clé d'API refusée : 401/403 sur l'authentification. + ("invalid_api_key", "authentication_error", "unauthorized", "401", + "403", "permission denied"), + "Vérifie / remplace la clé d'API refusée (401/403) auprès du fournisseur.", + ), +) + + +def _maintenant() -> float: + """Horloge de la sonde, isolée pour être injectable dans les tests.""" + return time.time() def _entier_env(nom: str, defaut: int) -> int: @@ -80,29 +151,131 @@ def peut_desactiver() -> bool: ) +# ── Backoff & classement de la cause ──────────────────────────────────────── + +def intervalle_backoff(echecs_consecutifs: int) -> float: + """ + Intervalle de re-test d'un modèle DÉJÀ éteint par la sonde, en secondes. + + Progression géométrique (doublement) depuis l'intervalle horaire de base, + plafonnée à une journée : + échecs 3 → 1 h, 4 → 2 h, 5 → 4 h, 6 → 8 h, 7 → 16 h, 8+ → 24 h. + + Justification : une panne actionnable (crédit, trust, binaire, clé) ne se + répare pas par la répétition — on la re-confirme de moins en moins souvent. + Le doublement atteint le plafond en ~5 re-tests (~1 à 2 jours), ce qui borne + la consommation de tokens (mesurée : ~3 s + vrais tokens par test) tout en + restant réactif : une panne passagère qui guérit seule est rattrapée dans + l'heure ou les deux qui suivent. Le plafond d'une journée garantit qu'un + modèle réparé par Axel est re-testé et réactivé le jour même, sans + intervention manuelle. + """ + if echecs_consecutifs <= SEUIL_ECHECS: + return float(INTERVALLE_DEFAUT_S) + exposant = echecs_consecutifs - SEUIL_ECHECS + return min(float(INTERVALLE_DEFAUT_S) * (2 ** exposant), float(PLAFOND_BACKOFF_S)) + + +def _categorie_actionnable(erreur: str) -> str | None: + """Catégorie actionnable reconnue (credit/trust/binaire/cle), sinon None.""" + bas = erreur.lower() + for categorie, motifs, _message in _PATTERNS_ACTIONNABLES: + if any(motif in bas for motif in motifs): + return categorie + return None + + +def classer_erreur(erreur: str | None) -> str: + """ + Classe la dernière erreur en « actionnable » (seul un humain peut réparer) ou + « transitoire » (inconnue, réseau, à re-tester). + + Règle de prudence : on ne classe comme actionnable que ce qu'on reconnaît de + façon fiable. Dans le doute, c'est transitoire — un modèle sain rangé par + erreur dans « actionnable » serait mis au placard pour la journée alors qu'il + guérit peut-être tout seul. + """ + if not erreur: + return "transitoire" + return "actionnable" if _categorie_actionnable(erreur) else "transitoire" + + +def _message_actionnable(model_id: str, categorie: str, erreur: str) -> str: + """Message unique et actionnable pour une cause reconnue.""" + for cat, _motifs, texte in _PATTERNS_ACTIONNABLES: + if cat == categorie: + return ( + f"[SANTÉ MODÈLES] {model_id} en panne ACTIONNABLE ({categorie}) : " + f"{texte} Dernière erreur : {erreur}" + ) + return f"[SANTÉ MODÈLES] {model_id} en panne actionnable : {erreur}" + + # ── État persistant ───────────────────────────────────────────────────────── +def _assurer_colonne_alerte() -> None: + """ + Migration douce : ajoute la colonne de suivi d'alerte si elle manque. + + Idempotente, sans toucher aux données existantes ni au schéma des autres + tables. Appelée en tête de cycle : la base de prod a été créée avant cette + colonne, elle doit la gagner sans recréer la table. + """ + from core.runtime_db import get_connection + conn = get_connection() + try: + colonnes = { + r[1] for r in conn.execute("PRAGMA table_info(model_health)") + } + if _COLONNE_ALERTE not in colonnes: + conn.execute( + f"ALTER TABLE model_health ADD COLUMN {_COLONNE_ALERTE} TEXT" + ) + conn.commit() + finally: + conn.close() + + def lire_sante(model_id: str) -> dict: """État connu d'un modèle ; valeurs neutres s'il n'a jamais été testé.""" from core.runtime_db import get_connection conn = get_connection() - ligne = conn.execute( - "SELECT model_id, dernier_test, dernier_succes, echecs_consecutifs, " - "succes_consecutifs, desactive_par_sonde, derniere_erreur, latence_ms " - "FROM model_health WHERE model_id = ?", - (model_id,), - ).fetchone() + # Lecture tolérante : sur une base créée avant l'ajout de la colonne d'alerte + # (et pas encore migrée), on retombe sur le SELECT historique et on renvoie + # la valeur neutre pour la colonne manquante. + try: + # Nom de colonne constant du module (jamais d'entrée utilisateur). + sql = ("SELECT model_id, dernier_test, dernier_succes, echecs_consecutifs, " # noqa: S608 + "succes_consecutifs, desactive_par_sonde, derniere_erreur, latence_ms, " + f"{_COLONNE_ALERTE} FROM model_health WHERE model_id = ?") + ligne = conn.execute(sql, (model_id,)).fetchone() + avec_alerte = True + except Exception: + ligne = conn.execute( + "SELECT model_id, dernier_test, dernier_succes, echecs_consecutifs, " + "succes_consecutifs, desactive_par_sonde, derniere_erreur, latence_ms " + "FROM model_health WHERE model_id = ?", + (model_id,), + ).fetchone() + avec_alerte = False if ligne is None: return { "model_id": model_id, "dernier_test": None, "dernier_succes": None, "echecs_consecutifs": 0, "succes_consecutifs": 0, "desactive_par_sonde": 0, "derniere_erreur": None, "latence_ms": None, + _COLONNE_ALERTE: None, } - return dict(ligne) if hasattr(ligne, "keys") else { + if hasattr(ligne, "keys"): + d = dict(ligne) + if not avec_alerte: + d[_COLONNE_ALERTE] = None + return d + return { "model_id": ligne[0], "dernier_test": ligne[1], "dernier_succes": ligne[2], "echecs_consecutifs": ligne[3], "succes_consecutifs": ligne[4], "desactive_par_sonde": ligne[5], "derniere_erreur": ligne[6], "latence_ms": ligne[7], + _COLONNE_ALERTE: ligne[8] if avec_alerte else None, } @@ -110,6 +283,7 @@ def lire_sante(model_id: str) -> dict: _COLONNES_SANTE = frozenset({ "dernier_test", "dernier_succes", "echecs_consecutifs", "succes_consecutifs", "desactive_par_sonde", "derniere_erreur", "latence_ms", + _COLONNE_ALERTE, }) @@ -188,6 +362,21 @@ async def tester_modele(model_id: str, gateway=None) -> tuple[bool | None, str, # ── Cycle complet ─────────────────────────────────────────────────────────── +def _retest_du_a_backoff(model_id: str) -> bool: + """ + Vrai si le moment de re-tester un modèle ÉTEINT par la sonde est venu. + + Compare le temps écoulé depuis le dernier test à l'intervalle de backoff + dicté par ses échecs consécutifs. Un modèle jamais testé est testé. + """ + etat = lire_sante(model_id) + dernier_test = etat["dernier_test"] + if dernier_test is None: + return True + echecs = etat["echecs_consecutifs"] or 0 + return (_maintenant() - dernier_test) >= intervalle_backoff(echecs) + + async def executer_cycle(gateway=None) -> dict: """ Un passage : teste les modèles actifs + ceux que la sonde a éteints, puis @@ -195,6 +384,9 @@ async def executer_cycle(gateway=None) -> dict: """ from core.models_db import get_active_models, set_model_status + # Migration douce de la colonne d'alerte (base de prod créée avant elle). + _assurer_colonne_alerte() + # `get_active_models()` renvoie la clé SQL `id` (PK), pas `model_id`. actifs = [m["id"] for m in get_active_models() if m.get("id")] eteints_par_sonde = modeles_eteints_par_la_sonde() @@ -202,8 +394,26 @@ async def executer_cycle(gateway=None) -> dict: # les rallumer quand l'abonnement est renouvelé ou l'incident terminé. a_tester = list(dict.fromkeys(actifs + eteints_par_sonde)) + # Backoff : un modèle déjà éteint par la sonde n'est PAS re-testé à chaque + # cycle horaire — l'intervalle de re-test croît avec ses échecs consécutifs + # (`intervalle_backoff`), plafonné à une journée. Les modèles ACTIFS, eux, + # restent testés à chaque cycle : c'est eux qu'il faut éteindre vite. + reportes_backoff = 0 + filtre = [] + for model_id in a_tester: + if model_id in actifs: + filtre.append(model_id) + continue + if _retest_du_a_backoff(model_id): + filtre.append(model_id) + else: + reportes_backoff += 1 + a_tester = filtre + if not a_tester: - return {"testes": 0, "vivants": 0, "morts": 0, "desactives": [], "reactives": []} + return {"testes": 0, "vivants": 0, "morts": 0, "ignores_non_cables": 0, + "reportes_backoff": reportes_backoff, + "desactives": [], "reactives": []} verrou = asyncio.Semaphore(CONCURRENCE) @@ -220,7 +430,7 @@ async def _tester(model_id: str): if isinstance(resultat, BaseException): continue model_id, (ok, erreur, latence) = resultat - maintenant = time.time() + maintenant = _maintenant() etat = lire_sante(model_id) if ok is None: # Non câblé : hors statistiques (sinon ils « sauvent » le plancher @@ -234,6 +444,10 @@ async def _tester(model_id: str): echecs_consecutifs=0, succes_consecutifs=(etat["succes_consecutifs"] or 0) + 1, derniere_erreur=None, latence_ms=latence, + # Succès = la cause actionnable éventuelle est réparée : on + # efface le marqueur pour qu'une prochaine panne actionnable + # (même cause) soit re-signalée, et non avalée. + **{_COLONNE_ALERTE: None}, ) else: morts.append(model_id) @@ -242,12 +456,20 @@ async def _tester(model_id: str): echecs_consecutifs=(etat["echecs_consecutifs"] or 0) + 1, succes_consecutifs=0, derniere_erreur=erreur, latence_ms=latence, ) + # Alerte-une-fois : une cause actionnable produit UN message clair + # quand elle apparaît ou quand elle change, pas à chaque cycle. + categorie = _categorie_actionnable(erreur or "") + signalee = etat.get(_COLONNE_ALERTE) + if categorie and categorie != signalee: + logger.warning(_message_actionnable(model_id, categorie, erreur)) + _ecrire_sante(model_id, **{_COLONNE_ALERTE: categorie}) compte_rendu = { "testes": len(vivants) + len(morts), "vivants": len(vivants), "morts": len(morts), "ignores_non_cables": len(ignores), + "reportes_backoff": reportes_backoff, "desactives": [], "reactives": [], "gele": False, diff --git a/core/models_db.py b/core/models_db.py index 295d4f5..8717c7b 100644 --- a/core/models_db.py +++ b/core/models_db.py @@ -402,6 +402,94 @@ def get_models_for_catalog_tiers(catalog_tiers: list[str]) -> list[dict[str, Any return [] +# Les trois niveaux de capacité du routeur (colonne `routing_tier`). +ROUTING_TIERS = ("leger", "moyen", "fort") + +# Règle explicite de la zone grise (mesure prod du 17/08 : 52 modèles actifs +# sans routing_tier, sondés chaque heure, jamais choisis par le routeur) : +# un modèle SANS tier est légitime si sa spécialité le place hors du routage +# par cascade de chat-complétion — embeddings, audio, image… Ces modèles ne +# sont appelables QUE par nom explicite (RAG, pipelines, `model_override`) et +# n'ont donc pas vocation à porter un leger/moyen/fort. Tout modèle actif sans +# tier dont la spécialité n'est pas dans cette liste est une anomalie de +# catalogue : il coûte des tokens à la sonde de vivacité sans pouvoir servir. +SPECIALITES_HORS_ROUTAGE = frozenset({ + "embeddings", "stt", "tts", "transcription", "audio", + "image", "images", "generation_images", "video", "videos", + "traduction", "translation", +}) + + +def compter_profondeur_tiers() -> dict[str, int]: + """ + Profondeur réelle du routage : nombre de modèles ACTIFS par routing_tier. + + La clé `hors_tier` compte les modèles actifs au tier NULL — la zone grise + que le routeur ne choisit jamais (`get_models_for_tier` filtre sur le tier) + mais que la sonde de vivacité teste à chaque cycle. + """ + try: + conn = _get_connection() + lignes = conn.execute( + "SELECT COALESCE(routing_tier, 'hors_tier') AS tier, COUNT(*) AS nb " + "FROM models WHERE status = 'active' " + "GROUP BY COALESCE(routing_tier, 'hors_tier')" + ).fetchall() + resultat = {tier: 0 for tier in ROUTING_TIERS} + resultat["hors_tier"] = 0 + for ligne in lignes: + resultat[ligne[0]] = ligne[1] + return resultat + except Exception as e: + logger.warning(f"[ModelsDB] Erreur compter_profondeur_tiers: {e}") + return {} + + +def get_active_models_hors_tier() -> list[dict[str, Any]]: + """Modèles actifs SANS routing_tier : routables par aucun tier, sondés quand même.""" + try: + conn = _get_connection() + lignes = conn.execute( + "SELECT * FROM models WHERE status = 'active' AND routing_tier IS NULL ORDER BY id" + ).fetchall() + return [dict(r) for r in lignes] + except Exception as e: + logger.warning(f"[ModelsDB] Erreur get_active_models_hors_tier: {e}") + return [] + + +def est_hors_routage_legitime(modele: dict[str, Any]) -> bool: + """Applique la règle de la zone grise à UN modèle (cf. SPECIALITES_HORS_ROUTAGE).""" + specialite = (modele.get("speciality") or "").strip().lower() + return specialite in SPECIALITES_HORS_ROUTAGE + + +def audit_zone_grise(seuil: int = 0) -> dict[str, Any]: + """ + Audit de la zone grise contre la règle explicite. Retourne le compte rendu + et le verdict : + + - `hors_tier` : ids des modèles actifs sans routing_tier ; + - `justifies` : ceux dont la spécialité est hors routage (légitimes) ; + - `zone_grise` : les autres — actifs, sondés, jamais choisis par le routeur ; + - `conforme` : `len(zone_grise) <= seuil`. + + Seuil 0 par défaut : chaque modèle actif sans tier ni justification de + spécialité est une anomalie (coût de sonde pour zéro service de routage). + La non-récidive est verrouillée par tests/unit/test_zone_grise_catalogue.py. + """ + hors_tier = get_active_models_hors_tier() + justifies = [m["id"] for m in hors_tier if est_hors_routage_legitime(m)] + zone_grise = [m["id"] for m in hors_tier if m["id"] not in set(justifies)] + return { + "hors_tier": [m["id"] for m in hors_tier], + "justifies": justifies, + "zone_grise": zone_grise, + "seuil": seuil, + "conforme": len(zone_grise) <= seuil, + } + + def get_all_models() -> list[dict[str, Any]]: """ [#T158] Retourne TOUS les modèles, actifs ET inactifs. diff --git a/core/openai_compat_provider.py b/core/openai_compat_provider.py index de59053..4201b49 100644 --- a/core/openai_compat_provider.py +++ b/core/openai_compat_provider.py @@ -23,6 +23,7 @@ import asyncio import json import logging +import os import threading from typing import Any @@ -39,6 +40,47 @@ # et certains renvoient le prompt en écho — inutile d'en inonder le journal. _MAX_CORPS_ERREUR = 500 +# [T316] Repli du plafond de sortie par défaut des providers OpenAI-compatibles. +# Une génération en boucle peut partir jusqu'au plafond par défaut du provider +# (Cerebras 40 000 tokens — mesuré en prod le 16/08) sans qu'aucun appelant n'ait +# posé de `max_tokens`. On applique donc un plafond par défaut, dérivé du +# `context_output` du catalogue (core.models_db.get_model) ; ce repli ne sert que +# pour les modèles absents du catalogue ou à `context_output` nul. Surchargeable +# par la variable d'environnement `MOTEUR_MAX_OUTPUT_TOKENS` (entier > 0). +_DEFAULT_MAX_OUTPUT_TOKENS = 8192 + + +def _repli_max_output_tokens() -> int: + """Plafond de sortie de repli des providers OpenAI-compatibles (#T316). + + Lit `MOTEUR_MAX_OUTPUT_TOKENS` (entier strictement positif) ; sinon + `_DEFAULT_MAX_OUTPUT_TOKENS`. N'est utilisé que quand le modèle est absent du + catalogue ou à `context_output` nul — jamais quand l'appelant fournit + `max_tokens`. + """ + brut = os.environ.get("MOTEUR_MAX_OUTPUT_TOKENS", "").strip() + if brut.isdigit() and int(brut) > 0: + return int(brut) + return _DEFAULT_MAX_OUTPUT_TOKENS + + +def _tokens_cache_hit(usage: dict) -> int: + """Compteur de tokens d'entrée servis par le cache (#T347). + + L'API le renvoie sous deux formes : `prompt_cache_hit_tokens` (DeepSeek) ou + `prompt_tokens_details.cached_tokens` (forme normalisée OpenAI, renvoyée par + d'autres providers compatibles). La première a la priorité ; à défaut la + seconde ; absent → 0. On ne suppose JAMAIS un taux de cache : seul le + compteur renvoyé par l'API fait foi. + """ + if not isinstance(usage, dict): + return 0 + cache_hit = usage.get("prompt_cache_hit_tokens") + if cache_hit is not None: + return int(cache_hit) if cache_hit else 0 + details = usage.get("prompt_tokens_details") or {} + return int(details.get("cached_tokens", 0) or 0) + def lever_pour_statut(response, *, provider: str = "?", modele: str = "?") -> None: """ @@ -280,6 +322,7 @@ def _record_usage(self, usage: dict, session_id: str = None): usage.get("prompt_tokens", 0), usage.get("completion_tokens", 0), session_id=session_id, + cache_hit_tokens=_tokens_cache_hit(usage), ) except Exception as e: logger.debug(f"[{self.provider_name}] Erreur token_tracker : {e}") @@ -298,6 +341,29 @@ def _estimate_and_record_stream_usage(self, messages: list, total_text: str, ses except Exception as e: logger.debug(f"[{self.provider_name}] Erreur estimation streaming : {e}") + def _max_tokens_par_defaut(self) -> int: + """Plafond de sortie par défaut du modèle (#T316). + + Dérivé du `context_output` du catalogue (`core.models_db.get_model`), + avec repli sur `MOTEUR_MAX_OUTPUT_TOKENS` (puis 8192) si le modèle est + absent du catalogue ou à `context_output` nul. N'est appliqué QUE quand + l'appelant ne fournit pas `max_tokens` : un appelant qui en pose un n'est + jamais écrasé (voir les trois méthodes de génération). + """ + try: + from core.models_db import get_model + modele = get_model(self.model) + if modele: + ctx_output = modele.get("context_output") + if ctx_output: + return int(ctx_output) + except Exception as e: + logger.debug( + f"[{self.provider_name}] get_model({self.model}) indisponible " + f"({e}) — repli sur le défaut." + ) + return _repli_max_output_tokens() + # ────────────────────────────────────────────────────────────── # generate() — Appel standard (non-streaming) # ────────────────────────────────────────────────────────────── @@ -330,9 +396,13 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: if kwargs.get("tool_choice") is not None: payload["tool_choice"] = kwargs["tool_choice"] - # Support du max_tokens si fourni + # [T316] Plafond de sortie : celui de l'appelant s'il en pose un, sinon le + # défaut dérivé du catalogue (context_output) — pour borner une génération + # en boucle qui partirait jusqu'au plafond par défaut du provider. if "max_tokens" in kwargs: payload["max_tokens"] = kwargs["max_tokens"] + else: + payload["max_tokens"] = self._max_tokens_par_defaut() logger.debug(f"Appel API {self.provider_name} ({self.model}) (generate)") # Utilisation du pool HTTP persistant (réutilisé entre les appels) @@ -343,7 +413,20 @@ def generate(self, system_prompt: str, user_prompt: str, **kwargs) -> Any: lever_pour_statut(response, provider=self.provider_name, modele=self.model) resp_json = response.json() - self._record_usage(resp_json.get("usage"), session_id=kwargs.get("session_id")) + usage = resp_json.get("usage") + self._record_usage(usage, session_id=kwargs.get("session_id")) + + # Canal latéral d'usage réel : l'appelant peut passer un dict récepteur + # via `_usage_sink` (préfixe "_" : jamais transmis au payload HTTP). + # generate() rend une chaîne dans le cas courant — le type de retour ne + # peut donc pas porter l'usage, et AUCUN appelant existant ne change : + # sans sink, le comportement est strictement identique. Le dict est propre + # à la requête (créé par l'appelant), donc aucune course entre requêtes + # concurrentes. L'usage est écrit TEL QUE renvoyé par le provider : + # aucun comptage n'est estimé ni recalculé. + _usage_sink = kwargs.get("_usage_sink") + if _usage_sink is not None and isinstance(_usage_sink, dict) and usage: + _usage_sink["usage"] = usage message = resp_json["choices"][0]["message"] @@ -383,6 +466,8 @@ async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) - payload["tool_choice"] = kwargs["tool_choice"] if "max_tokens" in kwargs: payload["max_tokens"] = kwargs["max_tokens"] + else: + payload["max_tokens"] = self._max_tokens_par_defaut() logger.debug(f"Appel API {self.provider_name} ({self.model}) (generate_async)") # httpx n'accepte pas le tuple (connect, read) de requests → convertir. @@ -401,6 +486,12 @@ async def generate_async(self, system_prompt: str, user_prompt: str, **kwargs) - self._record_usage, resp_json.get("usage"), kwargs.get("session_id") ) + # Même canal latéral que generate() : miroir fidèle du chemin synchrone, + # pour que le contrat d'usage soit identique quelle que soit la variante. + _usage_sink = kwargs.get("_usage_sink") + if _usage_sink is not None and isinstance(_usage_sink, dict) and resp_json.get("usage"): + _usage_sink["usage"] = resp_json["usage"] + message = resp_json["choices"][0]["message"] if "tool_calls" in message: return message @@ -429,6 +520,8 @@ def generate_structured( if "max_tokens" in kwargs: payload["max_tokens"] = kwargs["max_tokens"] + else: + payload["max_tokens"] = self._max_tokens_par_defaut() logger.debug(f"Appel API {self.provider_name} ({self.model}) (generate_structured)") # Pool HTTP persistant @@ -467,9 +560,13 @@ def generate_structured( def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): """ Streaming natif via stream=true (Server-Sent Events). - + Yields: - dict: {"token": str, "done": bool, "usage": dict|None} + dict: {"token": str, "done": bool, "usage": dict|None} pour le texte + de réponse, ou {"reasoning": str, "done": False, "usage": None} pour + le raisonnement (#T346). Les deux canaux ne sont JAMAIS mélangés dans + un même chunk yieldé : ce qui part sous "reasoning" ne doit jamais se + retrouver dans la réponse rendue ni au TTS. """ messages = kwargs.get("messages") if messages: @@ -493,6 +590,14 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): "stream_options": {"include_usage": True}, } + # [T316] Plafond de sortie : celui de l'appelant s'il en pose un, sinon le + # défaut dérivé du catalogue (context_output) — même garde-fou anti-boucle + # que dans generate() / generate_async() / generate_structured(). + if "max_tokens" in kwargs: + payload["max_tokens"] = kwargs["max_tokens"] + else: + payload["max_tokens"] = self._max_tokens_par_defaut() + logger.debug(f"Appel API {self.provider_name} ({self.model}) (generate_stream)") # Pool HTTP persistant (stream=True via Session) _http = SharedHTTPPool.get_session() @@ -503,23 +608,33 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): lever_pour_statut(response, provider=self.provider_name, modele=self.model) total_tokens = "" + total_raisonnement = "" real_usage = None for line in response.iter_lines(decode_unicode=True): if not line or not line.startswith("data: "): continue data_str = line[6:].strip() if data_str == "[DONE]": - yield {"token": "", "done": True, "usage": None} break try: chunk = json.loads(data_str) # Chunk final avec stream_options.include_usage : "choices" - # vide/absent, "usage" rempli — capturé mais rien à yield dessus. + # vide/absent, "usage" rempli — capturé puis remonté à l'appelant + # sur le chunk de clôture ci-dessous. chunk_usage = chunk.get("usage") if chunk_usage: real_usage = chunk_usage choices = chunk.get("choices") or [] delta = choices[0].get("delta", {}) if choices else {} + # [#T346] Canal raisonnement dédié : DeepSeek émet la pensée sous + # `delta.reasoning_content` (deepseek-reasoner), d'autres compatibles + # sous `delta.reasoning`. Ignoré jusque-là, le raisonnement était + # perdu à la lecture du chunk. On le yield sur son propre canal — + # JAMAIS mélangé à `token`, sinon il finirait au TTS dans le salon. + raisonnement = delta.get("reasoning_content") or delta.get("reasoning") or "" + if raisonnement: + total_raisonnement += raisonnement + yield {"reasoning": raisonnement, "done": False, "usage": None} token = delta.get("content", "") if token: total_tokens += token @@ -527,6 +642,10 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): except json.JSONDecodeError: continue + # L'écriture en base AVANT le yield de clôture : les consommateurs + # (proxy, pipeline, IHM) cassent leur boucle dès `done=True` et ne + # relancent jamais le générateur — une écriture placée après le yield + # ne serait pas exécutée de manière fiable. if real_usage: # Le provider a bien renvoyé l'usage réel via stream_options — # pas besoin d'estimer. @@ -536,19 +655,33 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): real_usage.get("prompt_tokens", 0), real_usage.get("completion_tokens", 0), session_id=kwargs.get("session_id"), + cache_hit_tokens=_tokens_cache_hit(real_usage), ) else: # Provider ignore stream_options.include_usage (pas tous ne le - # supportent) : repli sur l'estimation chars/4. + # supportent) : repli sur l'estimation chars/4, EN BASE UNIQUEMENT. + # Le raisonnement compte dans les tokens de sortie facturés : il + # entre dans l'estimation (sans jamais entrer dans la réponse). self._estimate_and_record_stream_usage( - messages, total_tokens, session_id=kwargs.get("session_id"), + messages, total_tokens + total_raisonnement, session_id=kwargs.get("session_id"), ) + # Chunk de clôture : il porte l'usage RÉEL quand le provider l'a + # renvoyé via stream_options.include_usage, et None sinon. L'éventuelle + # estimation de repli calculée pour la base n'y figure JAMAIS : + # aucun comptage n'est estimé ni recalculé pour le client. + yield {"token": "", "done": True, "usage": real_usage} + # ────────────────────────────────────────────────────────────────── # Registre de configuration des 9 providers OpenAI-compatibles # ────────────────────────────────────────────────────────────────── +# Modèle réellement chargé par AJEAN (llama.cpp b10451, llama-server.exe). +# Constante unique : le nom est celui que llama-server expose sur /v1/models, +# c'est-à-dire le nom du fichier .gguf sans l'extension. À ne pas disperser. +AJEAN_DEFAULT_MODEL = "Qwen2.5-14B-Instruct-1M-Q4_K_M" + # Ce dictionnaire permet de créer n'importe quel provider OpenAI-compatible # avec une seule ligne de configuration au lieu de ~130 lignes de classe. OPENAI_COMPAT_PROVIDERS = { @@ -579,10 +712,10 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): "openrouter": { "base_url": "https://openrouter.ai/api/v1/chat/completions", "env_key": "OPENROUTER_API_KEY", - "default_model": "meta-llama/llama-3.3-70b-instruct:free", + "default_model": "openrouter/auto", "description": "OpenRouter — Accès modèles gratuits et payants", "extra_headers": { - "HTTP-Referer": "https://github.com/AuxFilsDesIdees/moteur_agents", + "HTTP-Referer": "https://github.com/Axellum/vromvrom-engine", "X-Title": "Moteur Agents V9", }, }, @@ -627,17 +760,41 @@ def generate_stream(self, system_prompt: str, user_prompt: str, **kwargs): "base_url": "http://127.0.0.1:11434/v1/chat/completions", "env_key": "OLLAMA_API_KEY", # Pas de clé requise pour l'instance locale "default_model": "qwen2.5-coder:7b", - "description": "Ollama Local PC — Inférence locale ultra-rapide sur RTX 5070 Ti", + "description": "Ollama Local PC — Inférence locale ultra-rapide sur GPU locale", }, "ollama_pc": { - # Même IP LAN que LMStudioProvider (192.168.1.x, carte "Ethernet 4") — contrairement + # Même IP LAN que LMStudioProvider (192.168.1.10, carte "Ethernet 4") — contrairement # à ollama_local (127.0.0.1), joignable depuis le Deck en prod. Prérequis côté PC : # Ollama démarré avec OLLAMA_HOST=0.0.0.0 (ou au moins .84) + pare-feu Windows ouvert # sur 11434 pour le LAN, sinon connect timeout (repli cloud silencieux, pas d'erreur bruyante). - "base_url": "http://192.168.1.x:11434/v1/chat/completions", + "base_url": "http://192.168.1.10:11434/v1/chat/completions", "env_key": "OLLAMA_API_KEY", # Pas de clé requise pour l'instance locale "default_model": "domotique-qwen7b:q4", - "description": "Ollama PC via LAN — joignable depuis le Deck (RTX 5070 Ti, fine-tune domotique)", + "description": "Ollama PC via LAN — joignable depuis le Deck (GPU locale, fine-tune domotique)", + }, + # === AJEAN (llama.cpp / llama-server) — déclaratif, NON branché en cascade === + # Runtime llama.cpp du PC d'Axel (b10451, Qwen2.5-14B-Instruct-1M-Q4_K_M), API + # OpenAI-compatible sur le port 8080. Deux entrées symétriques : le PC d'Axel + # (192.168.1.10, joignable depuis le Deck) et le Deck lui-même (127.0.0.1, pour sa + # propre instance à venir). Hôtes déclarés par IP privée/loopback, JAMAIS par nom + # DNS : `LLMGateway._est_hote_local` (core/llm_gateway.py) classe toute IP privée + # comme locale (#T337, mode privacy_level=local_only) alors qu'un nom d'hôte serait + # traité comme externe/cloud. Pas de clé d'API : llama.cpp n'authentifie pas. + # ⚠️ PRÉREQUIS RÉSEAU (action d'Axel, hors périmètre) : llama-server écoute encore + # --host 127.0.0.1, donc 192.168.1.10:8080 est injoignable depuis le Deck. Il faut + # basculer sur 0.0.0.0 et ouvrir le pare-feu Windows sur 8080. Tant que ce n'est + # pas fait, ces providers échouent en connect timeout (repli cloud silencieux). + "ajean_pc": { + "base_url": "http://192.168.1.10:8080/v1/chat/completions", + "env_key": "AJEAN_API_KEY", # Pas de clé requise : llama.cpp n'authentifie pas + "default_model": AJEAN_DEFAULT_MODEL, + "description": "AJEAN PC via LAN — llama.cpp 14B (RTX, contexte 1M), joignable depuis le Deck", + }, + "ajean_deck": { + "base_url": "http://127.0.0.1:8080/v1/chat/completions", + "env_key": "AJEAN_API_KEY", # Pas de clé requise : llama.cpp n'authentifie pas + "default_model": AJEAN_DEFAULT_MODEL, + "description": "AJEAN Deck — llama.cpp 14B local (127.0.0.1), instance à venir", }, } diff --git a/core/pricing.py b/core/pricing.py index 89dc43f..4d54603 100644 --- a/core/pricing.py +++ b/core/pricing.py @@ -10,7 +10,9 @@ et expose `get_model_pricing(model)`. `token_tracker` délègue ici. - `apis[].rates[model]` : coût réel par million de tokens (`*_cost_per_m`, ou - `*_cost_per_m_eur` converti en USD via EUR_USD). + `*_cost_per_m_eur` converti en USD via EUR_USD). Le tarif d'entrée servi par + le cache est lu dans `input_cache_hit_cost_per_m` (USD) ou `cached_input_per_m_eur` + (EUR), exposé sous la clé `cache` ; absent, il retombe sur `input` (#T347). - `subscriptions[].models` et `local.models` : coût marginal nul (forfait/local). Un petit barème de repli (`FALLBACK_PRICING`) couvre les alias/legacy absents du @@ -37,10 +39,12 @@ EUR_USD = 1.08 # Barème de repli (alias/legacy + secours si pricing_strategy.json est absent). -# Valeurs en USD par token. +# Valeurs en USD par token. `cache` = tarif d'entrée servi par le cache (cache-hit). +# Quand le barème ne dit rien, `cache` vaut `input` (voir _normaliser_barème) : un +# modèle sans tarif cache coûte exactement ce qu'il coûtait avant cette PR. FALLBACK_PRICING = { - "deepseek-chat": {"input": 0.14 / 1_000_000, "output": 0.28 / 1_000_000}, - "deepseek-reasoner": {"input": 0.55 / 1_000_000, "output": 2.19 / 1_000_000}, + "deepseek-chat": {"input": 0.14 / 1_000_000, "output": 0.28 / 1_000_000, "cache": 0.003 / 1_000_000}, + "deepseek-reasoner": {"input": 0.55 / 1_000_000, "output": 2.19 / 1_000_000, "cache": 0.14 / 1_000_000}, "gemini-2.5-pro": {"input": 1.25 / 1_000_000, "output": 5.00 / 1_000_000}, "gemini-2.5-flash": {"input": 0.075 / 1_000_000, "output": 0.30 / 1_000_000}, "gemini": {"input": 0.075 / 1_000_000, "output": 0.30 / 1_000_000}, # alias générique @@ -67,8 +71,28 @@ def _rate_to_usd_per_token(rates: dict, kind: str) -> float: return 0.0 +def _rate_cache_to_usd_per_token(rates: dict) -> float | None: + """Convertit le tarif d'entrée cache-hit du JSON en USD par token. + + Deux formes coexistent dans `pricing_strategy.json` : + - USD : `input_cache_hit_cost_per_m` (DeepSeek, GLM, Mistral…) ; + - EUR : `cached_input_per_m_eur` (GCP, converti via EUR_USD), produit par + `core/models_db.py` sous le nom `cached_input_per_m_eur`. + + Renvoie None quand le barème ne dit rien : l'appelant retombe alors sur le + tarif d'entrée plein (`_normaliser_barème`). + """ + usd_m = rates.get("input_cache_hit_cost_per_m") + if usd_m is not None: + return float(usd_m) / 1_000_000 + eur_m = rates.get("cached_input_per_m_eur") + if eur_m is not None: + return float(eur_m) * EUR_USD / 1_000_000 + return None + + def _build_table(data: dict) -> dict: - """Aplatit pricing_strategy.json en {modèle_minuscule: {input, output}} (USD/token).""" + """Aplatit pricing_strategy.json en {modèle_minuscule: {input, output, cache}} (USD/token).""" table: dict = {} for api in data.get("apis", []): for model, rates in api.get("rates", {}).items(): @@ -77,6 +101,7 @@ def _build_table(data: dict) -> dict: table[model.lower()] = { "input": _rate_to_usd_per_token(rates, "input"), "output": _rate_to_usd_per_token(rates, "output"), + "cache": _rate_cache_to_usd_per_token(rates), } # Forfaits (abonnements) et local : coût marginal nul pour le tracking. for sub in data.get("subscriptions", []): @@ -87,6 +112,24 @@ def _build_table(data: dict) -> dict: return table +def _normaliser_barème(res: dict) -> dict: + """Garantit les clés {input, output, cache} ; `cache` retombe sur `input`. + + Le tarif cache n'est pas toujours renseigné (barème muet, repli, modèle + gratuit, clé absente du JSON). Dans ce cas les tokens cache-hit DOIVENT + coûter exactement le tarif d'entrée plein — jamais moins — pour ne rien + changer au coût affiché d'un modèle sans cache (rétrocompatibilité au + centime près). + """ + out = dict(res) + cache = out.get("cache") + if cache is None: + out["cache"] = out.get("input", 0.0) + out.setdefault("output", 0.0) + out.setdefault("input", 0.0) + return out + + def load_pricing_table() -> dict: """ Charge (et met en cache) le barème depuis pricing_strategy.json. @@ -136,7 +179,11 @@ def is_deepseek_peak_hours(now_utc: datetime.datetime) -> bool: def get_model_pricing(model: str) -> dict: """ - Retourne {"input": usd/token, "output": usd/token} pour `model`. + Retourne {"input": usd/token, "output": usd/token, "cache": usd/token} pour `model`. + + `cache` = tarif d'entrée servi par le cache (cache-hit). Quand le barème ne + le renseigne pas, il retombe sur `input` : un modèle sans tarif cache coûte + exactement ce qu'il coûtait avant cette PR (#T347). Ordre de résolution : 1. Modèles gratuits/forfait par convention de nom (-free / -cli / claude / local) → 0. @@ -147,10 +194,10 @@ def get_model_pricing(model: str) -> dict: """ m = (model or "").lower() if not m: - return dict(_FREE) + return _normaliser_barème(_FREE) if "-free" in m or "-cli" in m or m == "claude" or m == "local": - return dict(_FREE) + return _normaliser_barème(_FREE) table = load_pricing_table() res = None @@ -169,7 +216,11 @@ def get_model_pricing(model: str) -> dict: if res is None: res = dict(_FREE) - # Tarification pic-creux pour DeepSeek à partir de mi-juillet 2026 + res = _normaliser_barème(res) + + # Tarification pic-creux pour DeepSeek à partir de mi-juillet 2026. + # Le doublement s'applique au tarif cache comme aux autres, par cohérence + # (verdict #T347 : son bien-fondé n'est pas le sujet de cette PR). if "deepseek" in m and res: now_utc = datetime.datetime.now(datetime.timezone.utc) if is_deepseek_peak_hours(now_utc): diff --git a/core/quota_alert.py b/core/quota_alert.py index ed22893..4f53a51 100644 --- a/core/quota_alert.py +++ b/core/quota_alert.py @@ -28,7 +28,8 @@ requêtes de solde serait un amplificateur de panne, pas une surveillance. - UNE alerte par franchissement, jamais une par cycle : l'hystérésis (confirmation + sortie) est persistée en base ; un solde qui reste bas - dix cycles produit UNE alerte, pas dix. + dix cycles produit UNE alerte, pas dix. Seule une notification ÉCHOUÉE + est retentée au cycle suivant (#T343-B). - JAMAIS de secret dans une alerte : ni clé, ni token, même tronqué. Le provider, l'id de la clé (un nom d'environnement, pas sa valeur) et le chiffre suffisent. @@ -45,6 +46,13 @@ logger = logging.getLogger(__name__) +# [#T343-B] Sérialise les cycles concurrents : la boucle de fond et le bouton +# IHM (POST /api/quotas/alertes/cycle) peuvent s'exécuter en même temps. Sans +# verrou, pendant l'aller-retour de `notifier_alerte`, un second cycle lirait +# un état pas encore persisté (en_alerte=0) et renotifierait — deux +# notifications pour un seul franchissement, la garantie de #T334 rompue. +_verrou_cycle = asyncio.Lock() + # ── Réglages par environnement (aucune modification de code) ───────────────── # Seuil de saturation par défaut : aligné sur tools/comptes.py @@ -266,7 +274,20 @@ async def executer_cycle_alerte(resume: dict | None = None) -> dict: bout de `sortie_cycles()`, l'état est levé et le franchissement suivant pourra de nouveau alerter ; - au-dessus du seuil, pas en alerte → compteurs remis à zéro. + + #T343-B : la sortie exige des cycles CONSÉCUTIFS (un franchissement en + cours de sortie annule la sortie) ; l'état « en alerte » n'est persisté + qu'après une notification passée (ou en mode observation) — un échec + laisse la confirmation acquise et le cycle suivant retente. """ + # Voir `_verrou_cycle` : les deux appelants (boucle, bouton IHM) passent + # ici, et le verrou couvre lecture → décision → notification → écriture. + async with _verrou_cycle: + return await _cycle_alerte(resume) + + +async def _cycle_alerte(resume: dict | None) -> dict: + """Corps du cycle, exécuté sous `_verrou_cycle`.""" if resume is None: # Lecture seule de la base : AUCUN appel aux API de facturation. from core.models_db import get_quota_summary @@ -279,14 +300,16 @@ async def executer_cycle_alerte(resume: dict | None = None) -> dict: en_alerte = etat["en_alerte"] cycles_sous = etat["cycles_sous_seuil"] cycles_au_dessus = etat["cycles_au_dessus"] + derniere_alerte_ts = etat["derniere_alerte_ts"] if alertes: + # Un franchissement pendant une sortie en cours ANNULE la sortie : + # lever l'alerte exige des cycles CONSÉCUTIFS au-dessus du seuil. + cycles_au_dessus = 0 if not en_alerte: cycles_sous += 1 - cycles_au_dessus = 0 if cycles_sous >= max(confirmation_cycles(), 1): nouvelle_alerte = True - en_alerte = 1 # déjà en alerte : on ne re-notifie pas, compteurs stables else: cycles_sous = 0 @@ -298,14 +321,6 @@ async def executer_cycle_alerte(resume: dict | None = None) -> dict: else: cycles_au_dessus = 0 - _ecrire_etat( - en_alerte=en_alerte, - cycles_sous_seuil=cycles_sous, - cycles_au_dessus=cycles_au_dessus, - dernier_cycle_ts=maintenant, - derniere_alerte_ts=maintenant if nouvelle_alerte else etat["derniere_alerte_ts"], - ) - notifiee = False if nouvelle_alerte: message = formater_message(alertes) @@ -314,6 +329,14 @@ async def executer_cycle_alerte(resume: dict | None = None) -> dict: len(alertes), ", ".join(sorted({a["api_key_id"] for a in alertes})), ) notifiee = await notifier_alerte(message) + # L'état « en alerte » n'est persisté qu'après une notification passée + # (ou en mode observation) : un échec laisse en_alerte=0 avec la + # confirmation déjà acquise, donc le cycle suivant RETENTE au lieu + # d'enterrer l'alerte. Réémission bornée à l'échec : une fois notifiée, + # en_alerte=1 et plus aucune re-notification (#T343-B). + if notifiee or mode_observation(): + en_alerte = 1 + derniere_alerte_ts = maintenant elif alertes: logger.info( "[ALERTE QUOTAS] En alerte (déjà notifiée) : %d clé(s) sous tension — " @@ -326,6 +349,14 @@ async def executer_cycle_alerte(resume: dict | None = None) -> dict: cycles_au_dessus, sortie_cycles(), ) + _ecrire_etat( + en_alerte=en_alerte, + cycles_sous_seuil=cycles_sous, + cycles_au_dessus=cycles_au_dessus, + dernier_cycle_ts=maintenant, + derniere_alerte_ts=derniere_alerte_ts, + ) + return { "alertes": [a["api_key_id"] for a in alertes], "nb_alertes": len(alertes), diff --git a/core/quota_collector.py b/core/quota_collector.py index 78d90e2..12f1024 100644 --- a/core/quota_collector.py +++ b/core/quota_collector.py @@ -372,6 +372,19 @@ def refresh_all_quotas(include_claude: bool = False, force_claude: bool = False) update_real_billing(deepseek_balance_usd=ds_balance) except Exception: pass + # Historie le solde réel dans billing_history (#T349). Le solde est + # déjà en main (aucun appel réseau ajouté) ; la politique d'écriture + # (changement OU > 1 h) est portée par insert_balance_snapshot. + # Une erreur d'écriture ne fait jamais tomber le refresh : elle est + # journalisée et remontée dans result["errors"] (même motif que + # l'instantané de quotas, lignes 398-407). Un solde None n'écrit rien. + try: + from core.session_history import insert_balance_snapshot + result["balance_snapshot_rows"] = insert_balance_snapshot("deepseek", ds_balance) + except Exception as e: + result["balance_snapshot_rows"] = 0 + result["errors"].append(f"balance_history: {e}") + logger.warning(f"[QUOTAS] Erreur relevé solde billing_history : {e}") ok = update_quota_realtime("DEEPSEEK_API_KEY", **ds_kwargs) if ok: updated += 1 diff --git a/core/review_loop.py b/core/review_loop.py index a628e7a..8038b13 100644 --- a/core/review_loop.py +++ b/core/review_loop.py @@ -16,7 +16,13 @@ import os from typing import TYPE_CHECKING -from core.acceptance_contract import verifier_contrat +from core.acceptance_contract import ( + CLE_ETAT_INITIAL, + CLE_SUBSTITUTIONS, + cle_critere, + normaliser_criteres, + verifier_contrat, +) from core.state import ExecutionPhase, StateUpdate, TaskPayload if TYPE_CHECKING: @@ -40,13 +46,13 @@ class ReviewLoop: """ Boucle de revue automatique post-DAG. - + Workflow : 1. Agrège les résultats du DAG (snippets des StateUpdates réussis) 2. Soumet au ReviewerAgent pour évaluation 3. Si rejeté → Planner génère un plan correctif → Exécution → Re-review 4. Si approuvé ou max rounds atteint → Fin - + Le Reviewer fait un "soft-approve" pour les sévérités minor/info. """ @@ -62,12 +68,12 @@ async def run_review( ) -> bool: """ Exécute la boucle de revue post-DAG. - + Args: initial_objective: Objectif original de la requête utilisateur. max_rounds: Nombre maximum de rounds review-correction. on_event: Callback SSE asynchrone pour l'IHM. - + Returns: True si le code est validé, False si rejeté après tous les rounds. """ @@ -85,10 +91,12 @@ async def run_review( # répondre, interroger Home Assistant). Mesuré en prod le 10/08 : # ce raccourci auto-approuvait le plan et le contrat n'était jamais # évalué — c'était le dernier des trois verrous de #T253. - criteres = self._criteres_du_plan() + criteres, etats_initiaux, substitutions = self._contrat_complet() verdict = None if criteres: - rapport = await asyncio.to_thread(verifier_contrat, criteres) + rapport = await asyncio.to_thread( + verifier_contrat, criteres, etats_initiaux=etats_initiaux + ) if rapport.determinable: verdict = rapport.satisfait logger.info( @@ -102,6 +110,7 @@ async def run_review( "satisfied": verdict, "failures": len(rapport.echecs), "summary": rapport.resume(), + "substitutions": substitutions, "contexte": "sans_modification_de_fichier", }) @@ -134,11 +143,18 @@ async def run_review( # tranchent, pas un score de complaisance. Contrat satisfait → # validation sans appel LLM ; contrat en échec → correction directe, # l'avis d'un modèle ne peut pas contredire un test qui échoue. - # Rechargé à chaque round : un plan correctif peut avoir reposé - # un contrat plus récent dans l'historique. - criteres_contrat = self._criteres_du_plan() + # + # [Incident du 17/08] Le contrat évalué est l'UNION de tous les + # contrats de l'historique : un plan correctif peut enrichir le + # contrat, jamais remplacer ni affaiblir celui qui a été posé (ou + # approuvé) avant lui. Avant ce correctif, seul le plus récent + # était lu — un lot en échec pouvait se réécrire un examen plus + # facile à chaque replanification. + criteres_contrat, etats_initiaux, substitutions = self._contrat_complet() if criteres_contrat: - verdict_contrat = await asyncio.to_thread(verifier_contrat, criteres_contrat) + verdict_contrat = await asyncio.to_thread( + verifier_contrat, criteres_contrat, etats_initiaux=etats_initiaux + ) if verdict_contrat.determinable: if on_event: await on_event("contract_checked", { @@ -146,6 +162,8 @@ async def run_review( "satisfied": verdict_contrat.satisfait, "failures": len(verdict_contrat.echecs), "summary": verdict_contrat.resume(), + "substitutions": substitutions, + "contexte": "revue_post_dag", }) if verdict_contrat.satisfait: logger.info( @@ -287,15 +305,21 @@ async def contrat_seul(self, on_event=None) -> bool | None: if os.getenv("MOTEUR_CONTRAT_SUR_ECHEC", "1").strip().lower() in ("0", "false", "off", "no"): return None - criteres = self._criteres_du_plan() + criteres, etats_initiaux, substitutions = self._contrat_complet() if not criteres: return None - verdict = await asyncio.to_thread(verifier_contrat, criteres) + verdict = await asyncio.to_thread(verifier_contrat, criteres, etats_initiaux=etats_initiaux) if not verdict.determinable: + d_avance = len(verdict.resultats_satisfaits_d_avance) + note = ( + f" — dont {d_avance} satisfait(s) d'avance, exclus du verdict : " + "un contrat qui était déjà vrai avant le lot ne prouve rien." + if d_avance else "" + ) logger.info( "[REVIEW] [T253] DAG en échec et contrat non déterminable " - f"({len(verdict.non_verifiables)} critère(s) non vérifiable(s)) — verdict inchangé." + f"({len(verdict.non_verifiables)} critère(s) non vérifiable(s)){note} — verdict inchangé." ) return None @@ -305,36 +329,290 @@ async def contrat_seul(self, on_event=None) -> bool | None: "satisfied": verdict.satisfait, "failures": len(verdict.echecs), "summary": verdict.resume(), + "substitutions": substitutions, "contexte": "dag_en_echec", }) if verdict.satisfait: + # [Incident du 17/08] Un DAG en erreur absous par contrat est un + # événement rare et puissant : le journal doit dire EXACTEMENT ce + # qui a été évalué et en quoi cela diffère du contrat approuvé. + # Avant ce correctif, il fallait fouiller la base SQLite des heures + # plus tard pour s'apercevoir que le contrat évalué n'était plus + # celui qui avait été approuvé. logger.warning( "[REVIEW] [T253] ✅ Le DAG a signalé une erreur, mais le contrat d'acceptation " f"est SATISFAIT ({len(verdict.verifiables)} critère(s) vérifiés) : l'objectif est " - "prouvé atteint malgré l'échec d'une tâche." + f"prouvé atteint malgré l'échec d'une tâche.\n{self._trace_evaluation(verdict, substitutions)}" ) return True logger.error( "[REVIEW] [T253] ❌ DAG en échec ET contrat non satisfait : " - f"{len(verdict.echecs)} critère(s) en échec.\n{verdict.resume()}" + f"{len(verdict.echecs)} critère(s) en échec.\n{verdict.resume()}\n" + f"{self._trace_evaluation(verdict, substitutions)}" ) return False def _criteres_du_plan(self) -> list: """ - [#T253] Récupère le contrat d'acceptation posé par le Planner. + [#T253] Récupère le contrat d'acceptation évalué par la porte déterministe. + + [Incident du 17/08] Ce n'est PLUS « le plus récent de l'historique » : + c'est l'UNION de tous les contrats posés, sans exception. Un plan + correctif peut enrichir le contrat, jamais le remplacer + ni l'affaiblir — avant ce correctif, chaque échec donnait au lot une + occasion de se réécrire un examen plus facile, et le contrat approuvé + disparaissait de l'évaluation (mesuré en prod le 17/08 : le fichier + livrable exigé par le contrat approuvé n'a jamais été créé, et le lot + a été déclaré satisfait sur deux critères portant sur des fichiers qui + existaient déjà). + + Retourne des dicts bruts (format des metadata). L'ordre place le + contrat le plus récent en tête — enrichissement oblige, c'est lui qui + décrit le mieux l'état visé — puis les critères plus anciens jamais + retirés : l'ordre ne change rien au verdict, tous sont évalués. Le + plancher (contrat d'origine ou restauré) se reconnaît à sa position + dans l'historique, pas à sa place dans cette liste. + """ + criteres, _etats, _substitutions = self._contrat_complet() + return criteres - Il voyage dans les metadata de son StateUpdate ; on prend le plus récent, - car un plan correctif peut en avoir reposé un. + def _entrees_contrat(self) -> list[dict]: + """ + Toutes les entrées de contrat de l'historique, en ordre chronologique. + + La PREMIÈRE entrée est le plancher : le contrat posé par le plan + d'origine, ou — après une approbation humaine — celui restauré par + `services/approval_resume_service.py` depuis `hitl_pending_approvals`, + qui l'injecte en tête d'un historique neuf. Le service de reprise est + donc couvert par construction : son contrat restauré est toujours le + plancher, et aucun contrat correctif ultérieur ne peut l'évincer. """ from core.acceptance_contract import CLE_CONTRAT - for update in reversed(self._engine.state.history): + entrees = [] + for update in self._engine.state.history: criteres = (update.metadata or {}).get(CLE_CONTRAT) if criteres: - return criteres - return [] + entrees.append({ + "criteres": criteres, + "etat_initial": (update.metadata or {}).get(CLE_ETAT_INITIAL) or {}, + "substitutions": (update.metadata or {}).get(CLE_SUBSTITUTIONS) or [], + }) + return entrees + + def _contrat_complet(self) -> tuple[list, dict[int, bool], list]: + """ + [Incident du 17/08] Construit le contrat effectif à évaluer. + + Retourne `(critères, états initiaux, substitutions appliquées)` : + - les critères : union de tous les contrats de l'historique, après + application des substitutions déclarées valides ; + - les états initiaux : indexés sur la liste NORMALISÉE des critères + (comme `verifier_contrat` les ré-indexe), le premier constat posé + pour une même clé l'emportant toujours — c'est celui d'avant le + travail ; + - les substitutions appliquées, pour le journal et les événements. + """ + from core.acceptance_contract import union_contrats + entrees = self._entrees_contrat() + if not entrees: + return [], {}, [] + + union = union_contrats([e["criteres"] for e in entrees]) + normalises = normaliser_criteres(union) + + plancher = normaliser_criteres(entrees[0]["criteres"]) + substitutions = self._appliquer_substitutions(entrees, union, plancher) + if substitutions: + # Les critères substitués sortent de l'évaluation — de façon tracée, + # journalisée et réversible à la lecture : leurs remplaçants restent + # dans l'union et seront évalués à leur place. + union = self._retirer_substituees(union, substitutions) + normalises = normaliser_criteres(union) + + ajoutes = max(len(normalises) - len(plancher) + len(substitutions), 0) + if len(entrees) > 1: + logger.info( + f"[REVIEW] [CONTRAT] Union de {len(entrees)} contrat(s) de l'historique : " + f"plancher approuvé/initial = {len(plancher)} critère(s), " + f"contrat effectif = {len(normalises)} critère(s) " + f"({ajoutes} ajouté(s) par les plans correctifs, " + f"{len(substitutions)} substitution(s) déclarée(s) appliquée(s))." + ) + + etats = self._etats_initiaux_effectifs(entrees, normalises) + return union, etats, substitutions + + @staticmethod + def _retirer_substituees(union: list, substitutions: list) -> list: + """Écarte de l'union les critères du plancher substitués (tracé, jamais silencieux).""" + keys_a_retirer = set() + for sub in substitutions: + origine = normaliser_criteres(sub.get("origine")) + if origine: + keys_a_retirer.add(cle_critere(origine[0])) + return [ + brut for brut in union + if cle_critere(normaliser_criteres(brut)[0]) not in keys_a_retirer + ] + + def _appliquer_substitutions(self, entrees: list, union: list, plancher: list) -> list: + """ + Arbitrage de l'incident du 17/08 : un critère du plancher devenu + IMPOSSIBLE (le plan correctif a légitimement changé d'approche) peut + être remplacé — mais JAMAIS silencieusement. Chaque substitution doit + être déclarée explicitement par le plan correctif, viser un critère du + plancher, et son remplaçant doit être présent dans le contrat et ne pas + être satisfait d'avance. Toute déclaration invalide est écartée et + journalisée : le critère du plancher reste alors évalué tel quel. + + Seuls les plans CORRECTIFS (entrées d'index ≥ 1) peuvent déclarer une + substitution : le plancher ne se substitue pas à lui-même. + """ + keys_plancher = {cle_critere(c) for c in plancher} + keys_union = {cle_critere(c) for c in normaliser_criteres(union)} + etats = self._etats_initiaux_effectifs(entrees, normaliser_criteres(union)) + normalises_union = normaliser_criteres(union) + + appliquees = [] + for position, entree in enumerate(entrees): + for declaration in entree["substitutions"]: + if not isinstance(declaration, dict): + logger.warning("[REVIEW] [CONTRAT] Substitution ignorée : pas un objet.") + continue + origine = normaliser_criteres(declaration.get("origine")) + remplacant = normaliser_criteres(declaration.get("remplacant")) + if not origine or not remplacant: + logger.warning( + f"[REVIEW] [CONTRAT] Substitution ignorée (origine ou remplaçant illisible) : {declaration!r}" + ) + continue + cle_origine, cle_remplacant = cle_critere(origine[0]), cle_critere(remplacant[0]) + if position == 0: + logger.warning( + "[REVIEW] [CONTRAT] Substitution ignorée : seul un plan correctif " + "peut déclarer une substitution, pas le contrat d'origine." + ) + continue + if cle_origine not in keys_plancher: + logger.warning( + "[REVIEW] [CONTRAT] Substitution ignorée : elle ne cible pas un " + f"critère du contrat approuvé — {origine[0].libelle()!r}." + ) + continue + if cle_remplacant not in keys_union: + logger.warning( + "[REVIEW] [CONTRAT] Substitution ignorée : le critère remplaçant " + f"est absent du contrat — {remplacant[0].libelle()!r}." + ) + continue + index_remplacant = next( + (i for i, c in enumerate(normalises_union) if cle_critere(c) == cle_remplacant), + None, + ) + if etats.get(index_remplacant): + logger.warning( + "[REVIEW] [CONTRAT] Substitution ignorée : le critère remplaçant " + f"était satisfait d'avance — {remplacant[0].libelle()!r}." + ) + continue + appliquees.append({ + "origine": dict(declaration["origine"]), + "remplacant": dict(declaration["remplacant"]), + "motif": str(declaration.get("motif") or "").strip(), + }) + logger.warning( + "[REVIEW] [CONTRAT] SUBSTITUTION TRACÉE : le critère approuvé " + f"« {origine[0].libelle()} » est remplacé par « {remplacant[0].libelle()} » — " + f"motif : {declaration.get('motif') or 'non renseigné'}. " + "Le critère d'origine n'est plus évalué." + ) + return appliquees + + @staticmethod + def _etats_initiaux_effectifs(entrees: list, normalises: list) -> dict[int, bool]: + """ + États initiaux indexés sur la liste normalisée du contrat effectif. + + Les constats des entrées sont posés par rapport à LEUR propre liste + normalisée : on repasse par la clé de chaque critère pour ré-indexer. + Le PREMIER constat posé pour une clé l'emporte — c'est le plus proche + du début du travail. Une clé sans constat n'est jamais marquée + « satisfaite d'avance » : le doute rend le jugement plus strict. + """ + constats_par_cle: dict[tuple, bool] = {} + for entree in entrees: + normaux_entree = normaliser_criteres(entree["criteres"]) + for index_brut, valeur in (entree["etat_initial"] or {}).items(): + try: + index = int(index_brut) + except (TypeError, ValueError): + continue + if 0 <= index < len(normaux_entree): + constats_par_cle.setdefault(cle_critere(normaux_entree[index]), bool(valeur)) + return { + index: constats_par_cle[cle_critere(critere)] + for index, critere in enumerate(normalises) + if cle_critere(critere) in constats_par_cle + } + + def _trace_evaluation(self, verdict, substitutions: list) -> str: + """ + [Incident du 17/08] Trace d'audit du contrat évalué : provenance de + chaque critère (plancher approuvé ou plan correctif), critères + satisfaits d'avance, substitutions appliquées. Injectée dans le + journal à chaque verdict de contrat, pour que la divergence entre + contrat évalué et contrat approuvé soit visible sans fouiller la base. + """ + entrees = self._entrees_contrat() + lignes = ["[CONTRAT] Trace de l'évaluation :"] + if not entrees: + lignes.append(" - aucun contrat dans l'historique") + return "\n".join(lignes) + + plancher = normaliser_criteres(entrees[0]["criteres"]) + keys_plancher = {cle_critere(c) for c in plancher} + keys_substituees = set() + for sub in substitutions: + origine = normaliser_criteres(sub.get("origine")) + if origine: + keys_substituees.add(cle_critere(origine[0])) + + correctifs = 0 + for resultat in verdict.resultats: + cle = cle_critere(resultat.critere) + if cle in keys_plancher: + provenance = "plancher (contrat d'origine/approuvé)" + else: + correctifs += 1 + provenance = "plan correctif (ajouté après le contrat d'origine)" + note = " [satisfait d'avance — exclu du verdict]" if resultat.critere.satisfait_d_avance else "" + # Les critères fichier montrent aussi leur cible : la description + # seule peut masquer QUEL chemin a échoué — c'est précisément ce + # qu'on reprochait au journal d'avant l'incident. + cible = ( + f" — {resultat.critere.valeur}" + if resultat.critere.type in ("fichier_contient", "fichier_existe") else "" + ) + lignes.append(f" - {resultat.critere.libelle()}{cible} ← {provenance}{note}") + + if correctifs: + lignes.append( + f" ⚠️ {correctifs} critère(s) ne figurent PAS dans le contrat d'origine " + f"({len(plancher)} critère(s)) : ils ont été posés par un plan correctif." + ) + if keys_substituees: + lignes.append( + f" ⚠️ {len(keys_substituees)} critère(s) du contrat d'origine substitué(s) " + "(voir les lignes SUBSTITUTION TRACÉE ci-dessus)." + ) + for sub in substitutions: + lignes.append( + f" - substitution : « {sub['origine'].get('valeur')} » → " + f"« {sub['remplacant'].get('valeur')} » ({sub.get('motif') or 'motif non renseigné'})" + ) + return "\n".join(lignes) @staticmethod def _update_depuis_contrat(verdict) -> StateUpdate: @@ -398,7 +676,7 @@ def _get_dominant_category(self) -> str | None: async def _build_review_context(self) -> str: """ Agrège les résultats réussis du DAG pour le Reviewer. - + Si les tâches produisent une interface (détecté via metadata 'produces_ui' ou mots-clés dans l'objectif), un screenshot est capturé et analysé par le VisualQAService pour enrichir le contexte de review. @@ -583,8 +861,8 @@ def _has_modified_files(self) -> bool: item_path = os.path.join(project_root, item) if os.path.isdir(item_path) and os.path.exists(os.path.join(item_path, ".git")): git_dirs.append(item_path) - except Exception: - pass + except Exception as err: + logger.debug(f"[REVIEW] Détection des dépôts Git impossible : {err}") moteur_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) if moteur_dir not in git_dirs and os.path.exists(os.path.join(moteur_dir, ".git")): @@ -593,7 +871,7 @@ def _has_modified_files(self) -> bool: for git_dir in git_dirs: result = subprocess.run( ["git", "status", "--porcelain"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, + capture_output=True, text=True, cwd=git_dir, encoding='utf-8', errors='ignore' ) if result.returncode == 0: diff --git a/core/router.py b/core/router.py index 668e395..a552663 100644 --- a/core/router.py +++ b/core/router.py @@ -239,7 +239,7 @@ def __init__(self, default_agent: str = "planner", rag_engine=None, "weight": 1.2 }, "sysadmin": { - "keywords": ["ssh", "deck", "steamdeck", "popydeck", "uptime", "journalctl", "syslog", "système", "linux", "free -m", "df -h", "htop", "diagnostic", "vm", "reboot", "ping", "processus", "pid", "daemon", "service", "charge cpu", "ollama", "benchmark", "inferérence locale", "edge ai", "phi3", "gemma", "llama", "rdna2"], + "keywords": ["ssh", "deck", "steamdeck", "remote-host", "uptime", "journalctl", "syslog", "système", "linux", "free -m", "df -h", "htop", "diagnostic", "vm", "reboot", "ping", "processus", "pid", "daemon", "service", "charge cpu", "ollama", "benchmark", "inferérence locale", "edge ai", "phi3", "gemma", "llama", "rdna2"], "weight": 1.5 }, # Catégorie deck_edge : requêtes de tâches légères à router vers Ollama sur le Deck diff --git a/core/runtime_db.py b/core/runtime_db.py index 0952f19..d38098f 100644 --- a/core/runtime_db.py +++ b/core/runtime_db.py @@ -205,12 +205,17 @@ def _init_schema(conn: sqlite3.Connection) -> None: total_tokens INTEGER DEFAULT 0, cost_usd REAL DEFAULT 0.0, channel TEXT, - agent_name TEXT + agent_name TEXT, + cache_hit_tokens INTEGER DEFAULT 0 ) """) conn.execute("CREATE INDEX IF NOT EXISTS idx_token_ts ON token_usage(timestamp DESC)") conn.execute("CREATE INDEX IF NOT EXISTS idx_token_session ON token_usage(session_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_token_model ON token_usage(model)") + # [T347] Migration additive idempotente : `cache_hit_tokens` (tokens d'entrée + # servis par le cache) sur les bases préexistantes — CREATE TABLE IF NOT + # EXISTS ne l'ajoute pas à une table déjà créée. + _ensure_columns(conn, "token_usage", {"cache_hit_tokens": "INTEGER DEFAULT 0"}) conn.execute(""" CREATE TABLE IF NOT EXISTS ide_conversations ( diff --git a/core/session_history.py b/core/session_history.py index dbce6da..bd2ba63 100644 --- a/core/session_history.py +++ b/core/session_history.py @@ -306,6 +306,13 @@ def _ensure_token_table(conn: sqlite3.Connection) -> None: agent_name TEXT ) """) + # [T347] Migration additive idempotente : la colonne `cache_hit_tokens` + # (tokens d'entrée servis par le cache) n'existe pas sur les bases créées + # avant cette PR. CREATE TABLE IF NOT EXISTS ne l'ajoute pas aux tables + # préexistantes → ALTER TABLE ADD COLUMN, sans perte de ligne. + _cols = {r[1] for r in conn.execute("PRAGMA table_info(token_usage)")} + if "cache_hit_tokens" not in _cols: + conn.execute("ALTER TABLE token_usage ADD COLUMN cache_hit_tokens INTEGER DEFAULT 0") conn.execute(""" CREATE INDEX IF NOT EXISTS idx_token_ts ON token_usage(timestamp DESC) @@ -329,6 +336,7 @@ def record_token_usage( session_id: str | None = None, channel: str | None = None, agent_name: str | None = None, + cache_hit_tokens: int = 0, ) -> None: """ Enregistre un appel LLM individuel dans la table token_usage. @@ -344,8 +352,8 @@ def record_token_usage( """ INSERT INTO token_usage (session_id, timestamp, model, prompt_tokens, completion_tokens, - total_tokens, cost_usd, channel, agent_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + total_tokens, cost_usd, channel, agent_name, cache_hit_tokens) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( session_id, @@ -357,6 +365,7 @@ def record_token_usage( cost_usd, channel, agent_name, + cache_hit_tokens, ), ) conn.commit() @@ -616,9 +625,9 @@ def _q(channel: str, since_ts: float): def canonical_project(label: str) -> str: """Normalise un libellé de projet en retirant le préfixe de lettre de lecteur. - Unifie le split d'identité Claude (P3) : 'E--AuxFilsDesIdees-moteur-agents' et - 'h--AuxFilsDesIdees-moteur-agents' → 'AuxFilsDesIdees-moteur-agents'. Les libellés - sans préfixe (ex: 'antigravity-ide') sont renvoyés inchangés. + Unifie le split d'identité Claude (P3) : libellés dérivés du chemin Windows + (`E--workspace-engine` vs `h--workspace-engine`) vers un identifiant unique. + Les libellés sans préfixe (ex: 'antigravity-ide') sont renvoyés inchangés. """ if not label: return "" @@ -1109,6 +1118,110 @@ def get_quota_history(hours: int = 24, channel: str = None, metric: str = None) return [] +# Constante de la politique d'écriture des relevés de solde (#T349) : on n'écrit +# un nouveau relevé que si le solde a changé OU si le dernier relevé du même +# fournisseur a plus d'une heure. À l'intervalle de rafraîchissement (300 s), +# écrire systématiquement produirait 288 lignes/jour/prov pour une donnée qui +# bouge rarement ; la fenêtre d'une heure plafonne l'historique à ~24 lignes/jour +# par fournisseur tout en capturant chaque variation réelle de solde. +BALANCE_SNAPSHOT_MAX_AGE_SECONDS = 3600 # 1 h + + +def insert_balance_snapshot(provider: str, balance_usd: float | None) -> int: + """Enregistre un relevé de solde dans `billing_history` (#T349). + + Politique d'écriture : + - si `balance_usd` vaut `None` (fournisseur injoignable, HTTP en erreur, + clé absente) → AUCUNE ligne n'est écrite : un trou dans l'historique + est une information exploitable, un 0 ou un report de la valeur + précédente serait un mensonge dans un futur calcul de dépense ; + - sinon, on écrit si le solde a CHANGÉ depuis le dernier relevé du même + fournisseur, OU si ce dernier relevé a plus d'une heure ; + - sinon on n'écrit pas (pas de doublon). + + Retourne le nombre de lignes écrites (0 ou 1). + + Les erreurs SQLite **remontent** au caller : `refresh_all_quotas` les + journalise et les ajoute à `result["errors"]` sans faire tomber le refresh + (cf. `core/quota_collector.py`). Avaler ici et renvoyer 0 masquerait une + vraie panne comme un skip de déduplication. + """ + if balance_usd is None: + return 0 + + conn = _get_connection() # garantit le schéma canonique de billing_history + try: + # Dernier relevé du même fournisseur pour la même métrique. + dernier = conn.execute( + "SELECT timestamp, value FROM billing_history " + "WHERE provider = ? AND metric = 'balance_usd' " + "ORDER BY timestamp DESC LIMIT 1", + (provider,), + ).fetchone() + + now = time.time() + if dernier is not None: + ts_prev, val_prev = dernier + solde_change = abs(val_prev - balance_usd) > 1e-9 + trop_vieux = (now - ts_prev) > BALANCE_SNAPSHOT_MAX_AGE_SECONDS + if not solde_change and not trop_vieux: + return 0 + + conn.execute( + "INSERT INTO billing_history " + "(timestamp, provider, metric, value, currency, sync_source) " + "VALUES (?, ?, 'balance_usd', ?, 'USD', 'api')", + (now, provider, balance_usd), + ) + conn.commit() + return 1 + finally: + conn.close() + + +def get_balance_history(hours: int = 24, provider: str = None) -> list[dict]: + """Récupère l'historique des relevés de solde (`metric='balance_usd'`). + + Args: + hours: Fenêtre temporelle en heures (défaut 24 h). + provider: Filtrer par fournisseur (ex: 'deepseek'). None = tous. + + Retourne une liste ordonnée chronologiquement (ascendant). + """ + try: + conn = _get_connection() + since = time.time() - (hours * 3600) + + query = ( + "SELECT timestamp, provider, metric, value, currency, sync_source " + "FROM billing_history WHERE timestamp > ? AND metric = 'balance_usd'" + ) + params = [since] + + if provider: + query += " AND provider = ?" + params.append(provider) + + query += " ORDER BY timestamp ASC" + rows = conn.execute(query, params).fetchall() + conn.close() + + return [ + { + "timestamp": r[0], + "provider": r[1], + "metric": r[2], + "value": r[3], + "currency": r[4], + "sync_source": r[5], + } + for r in rows + ] + except Exception as e: + logger.warning(f"[SESSION HISTORY] Erreur get_balance_history : {e}") + return [] + + def insert_billing_record(provider: str, metric: str, value: float, currency: str = "USD", sync_source: str = "api") -> bool: """Enregistre un point de données de facturation. @@ -1222,7 +1335,7 @@ def cleanup_old_snapshots(retention_days: int = 90): def cleanup_zombie_sessions(now: float | None = None) -> int: """ - Marque comme 'error' les sessions zombies [#T298]. + Marque comme 'error' les sessions zombies [#T298] et réconcilie leurs tâches DAG. Une session est zombie si elle cumule DEUX conditions : - statut 'running' depuis plus de ZOMBIE_SESSION_MAX_AGE_SECONDS (1h) ; @@ -1236,36 +1349,85 @@ def cleanup_zombie_sessions(now: float | None = None) -> int: est la preuve que le moteur travaille encore : une session vivante a toujours un heartbeat bien plus récent qu'une heure. + Réconciliation DAG [#T362] : les tâches non terminales (pending/running) + rattachées aux sessions que CET appel vient de zombifier sont marquées + 'error' avec un message explicite et un ended_at renseigné, DANS LA MÊME + transaction que l'UPDATE sessions — jamais dans un passage séparé qui + pourrait s'exécuter alors qu'une autre session a démarré entre-temps. + Les tâches déjà terminales (success/error/blocked) gardent leur statut. + Le prédicat de zombification n'est pas élargi : les sessions + 'waiting_approval' (HITL en attente légitime) ne sont jamais touchées. + Args: now: Horodatage UNIX de référence (tests, temps simulé) ; None = time.time(). Returns: - Nombre de sessions zombies nettoyées. + Nombre de sessions zombies nettoyées (contrat conservé). + Le nombre de tâches réconciliées est exposé par le journal, pas par + la valeur de retour. """ try: reference = now if now is not None else time.time() cutoff = reference - ZOMBIE_SESSION_MAX_AGE_SECONDS with _db_lock: conn = _get_connection() - cursor = conn.execute( + # Identifie d'abord les sessions zombies, pour réconcilier ensuite + # leurs tâches DAG dans la même transaction (prédicat inchangé). + rows = conn.execute( """ - UPDATE sessions SET - status = 'error', - ended_at = ?, - error_message = 'Session zombie - nettoyée automatiquement (running depuis >1h)' + SELECT session_id FROM sessions WHERE status = 'running' AND started_at < ? AND (last_activity IS NULL OR last_activity < ?) """, - (reference, cutoff, cutoff), - ) - affected = cursor.rowcount + (cutoff, cutoff), + ).fetchall() + session_ids = [r[0] for r in rows] + affected = len(session_ids) + if affected > 0: + # Marque les sessions zombies 'error' (même prédicat que le SELECT). + conn.execute( + """ + UPDATE sessions SET + status = 'error', + ended_at = ?, + error_message = 'Session zombie - nettoyée automatiquement (running depuis >1h)' + WHERE status = 'running' + AND started_at < ? + AND (last_activity IS NULL OR last_activity < ?) + """, + (reference, cutoff, cutoff), + ) + reconciled = 0 + for sid in session_ids: + # Réconciliation des tâches non terminales des seules + # sessions zombifiées par CET appel — jamais un UPDATE + # dag_tasks global. Un UPDATE par session (le nombre de + # zombies est faible, ex. 46 dans la mesure du 17/08), + # dans la même transaction. + cursor_tasks = conn.execute( + """ + UPDATE dag_tasks SET + status = 'error', + ended_at = ?, + error_message = 'Tâche réconciliée : la session zombie a été nettoyée' + WHERE session_id = ? + AND status IN ('pending', 'running') + """, + (reference, sid), + ) + reconciled += cursor_tasks.rowcount + else: + reconciled = 0 conn.commit() conn.close() if affected > 0: - logger.warning(f"[SESSION HISTORY] {affected} session(s) zombie(s) nettoyée(s)") + logger.warning( + f"[SESSION HISTORY] {affected} session(s) zombie(s) nettoyée(s), " + f"{reconciled} tâche(s) DAG réconciliée(s)" + ) return affected except Exception as e: logger.warning(f"[SESSION HISTORY] Erreur cleanup_zombie_sessions : {e}") diff --git a/core/source_router.py b/core/source_router.py index 56deab7..cc0fd11 100644 --- a/core/source_router.py +++ b/core/source_router.py @@ -132,7 +132,7 @@ def get_system_prompt_suffix(self) -> str: ), ResponseStyle.NATURAL: ( "\n\n[MODE VOCAL CONVERSATION — DISCUSSION] " - "Tu es l'assistant vocal d'Axel sur sa tablette Tab5, en conversation libre. " + "Tu es l'assistant vocal de la maison, en conversation libre. " "Réponds en français, naturellement et brièvement : 2 à 3 phrases maximum. " "Pas de markdown, pas de listes, pas de titres, pas d'emojis, pas de code. " "Pas de noms d'entités techniques Home Assistant sauf si Axel le demande. " diff --git a/core/token_tracker.py b/core/token_tracker.py index 3844377..9f3ee59 100644 --- a/core/token_tracker.py +++ b/core/token_tracker.py @@ -345,7 +345,7 @@ def init_session(session_id: str, objective: str): except Exception as e: logger.warning(f"[TOKEN TRACKER] Erreur init_session SQLite : {e}") -def record_usage(model: str, prompt_tokens: int, completion_tokens: int, session_id: str = None, cost_usd: float = None, agent_name: str = None): +def record_usage(model: str, prompt_tokens: int, completion_tokens: int, session_id: str = None, cost_usd: float = None, agent_name: str = None, cache_hit_tokens: int = 0): """Enregistre la consommation de tokens pour un modèle donné directement dans SQLite. [#T296] `agent_name` : à défaut d'être passé explicitement, il est lu dans la @@ -353,6 +353,12 @@ def record_usage(model: str, prompt_tokens: int, completion_tokens: int, session call-sites des providers n'ont donc rien à transporter — un provider ne connaît de toute façon pas l'agent. Reste None hors agent (routeur, collecteur de quotas, script direct) : une absence légitime, pas un défaut. + + [#T347] `cache_hit_tokens` : nombre de tokens d'entrée servis par le cache + (compteur renvoyé par l'API, cf. `prompt_cache_hit_tokens` / + `prompt_tokens_details.cached_tokens`). Ces tokens sont facturés au tarif + cache (`price["cache"]`) au lieu du tarif d'entrée plein. Défaut 0 : les ~15 + call-sites qui ne passent rien gardent leur coût au centime près. """ if prompt_tokens <= 0 and completion_tokens <= 0: return @@ -368,7 +374,15 @@ def record_usage(model: str, prompt_tokens: int, completion_tokens: int, session if cost_usd is not None: cost = cost_usd else: - cost = (prompt_tokens * price["input"]) + (completion_tokens * price["output"]) + # [T347] Les tokens d'entrée servis par le cache sont facturés au tarif + # cache, le reste de l'entrée au tarif plein, la sortie à son tarif. + cache_hit_tokens = max(0, min(cache_hit_tokens, prompt_tokens)) + cache_miss = prompt_tokens - cache_hit_tokens + cost = ( + (cache_miss * price["input"]) + + (cache_hit_tokens * price["cache"]) + + (completion_tokens * price["output"]) + ) try: from core.session_history import record_token_usage @@ -381,6 +395,7 @@ def record_usage(model: str, prompt_tokens: int, completion_tokens: int, session session_id=session_id, channel=channel, agent_name=agent_name, + cache_hit_tokens=cache_hit_tokens, ) except Exception as e: logger.warning(f"[TOKEN TRACKER] Erreur record_usage SQLite : {e}") diff --git a/core/version_info.py b/core/version_info.py new file mode 100644 index 0000000..36c978e --- /dev/null +++ b/core/version_info.py @@ -0,0 +1,106 @@ +""" +core/version_info.py — Identité du code en cours d'exécution (SHA + date du commit). + +Problème résolu (#T355) : aucune surface du moteur ne disait quelle version du +code tournait. Le 17/08 la prod tournait sur `e64ebac` alors que `master` était à +`bfcb7fb`, neuf PR plus loin. Le seul moyen de le savoir a été d'ouvrir une session +SSH et de lancer `git log`. + +Contrat : +- La résolution est effectuée UNE seule fois (au premier appel) puis mise en cache : + c'est une information qui ne change pas pendant la vie du process, et lancer `git` + à chaque requête sur une sonde appelée en boucle par Home Assistant serait une + régression de performance. +- Si le commit ne peut PAS être déterminé (git absent, dépôt absent, commande en + erreur), la réponse le dit EXPLICITEMENT et sans ambiguïté : `commit` et + `commit_date` valent `None` et `reason` porte un motif lisible. On ne renvoie + JAMAIS une valeur d'apparence valide (« unknown », chaîne vide, version de + fichier non régénéré) : un exploitant qui lit une version croit savoir ce qui + tourne. +- `git` est disponible en prod (conteneur podman, dépôt monté) mais PAS garanti + partout : d'où le repli explicite ci-dessus plutôt qu'une exception. +""" + +import logging +import subprocess +import threading +from pathlib import Path + +logger = logging.getLogger(__name__) + +# Racine du dépôt : le parent du package `core` (ce fichier vit dans core/). +_REPO_ROOT = Path(__file__).resolve().parent.parent + +# Cache de la résolution (une seule exécution de `git` pour toute la vie du process). +_resolved = False +_cached_info: dict | None = None +_lock = threading.Lock() + + +def _run_git(args: list[str]) -> str | None: + """Exécute `git ` dans la racine du dépôt ; retourne stdout nettoyé ou None.""" + try: + proc = subprocess.run( # noqa: S603, S607 — usage sûr, pas de shell + ["git", *args], + cwd=str(_REPO_ROOT), + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except Exception as exc: + # git absent (FileNotFoundError), dépôt inaccessible, timeout, etc. + logger.warning("[VERSION] `git %s` indisponible : %s", " ".join(args), exc) + return None + if proc.returncode != 0: + logger.warning("[VERSION] `git %s` a échoué (code %s)", " ".join(args), proc.returncode) + return None + return proc.stdout.strip() + + +def _resolve() -> dict: + """Résout SHA court + date du commit HEAD. Ne doit jamais renvoyer de valeur valide en échec.""" + short_commit = _run_git(["rev-parse", "--short", "HEAD"]) + commit_date = _run_git(["show", "-s", "--format=%cI", "HEAD"]) + + if short_commit is None or commit_date is None: + # On ne connaît pas l'identité du code : le dire explicitement. + if short_commit is None: + reason = "commit indéterminable : git a échoué ou est absent sur ce process" + else: + reason = "date du commit indéterminable : git a échoué ou est absent sur ce process" + logger.warning("[VERSION] %s", reason) + return {"commit": None, "commit_date": None, "reason": reason} + + return { + "commit": short_commit, + "commit_date": commit_date, + "reason": None, + } + + +def get_version_info() -> dict: + """Retourne l'identité du code en cours, résolue une seule fois puis mise en cache. + + Retourne un dict avec les clés : + - `commit` : SHA court (str) ou `None` si indéterminable. + - `commit_date` : date ISO du commit (str) ou `None` si indéterminable. + - `reason` : motif lisible si indéterminable, sinon `None`. + """ + global _resolved, _cached_info + if _resolved: + return _cached_info + with _lock: + if not _resolved: + _cached_info = _resolve() + _resolved = True + return _cached_info + + +def get_short_commit() -> str | None: + """Retourne uniquement le SHA court (pour la sonde publique /healthz), ou None. + + N'ajoute RIEN d'autre (pas de chemin, pas de branche, pas de nom de machine) + sur la route publique. Réutilise le cache : aucun `git` supplémentaire. + """ + return get_version_info().get("commit") diff --git a/core/vocal_host.py b/core/vocal_host.py index c424a73..10fc3b4 100644 --- a/core/vocal_host.py +++ b/core/vocal_host.py @@ -36,11 +36,49 @@ class VocalIntent(str, Enum): "dernier modele", "dernier modèle", "derniere version", "dernière version", "vient de sortir", "qui est sorti", "modele sorti", "modèle sorti", "nouveaute", "nouveauté", "sortie de", + # Météo demandée SANS le mot « météo » (#T351) — formulations réelles du + # vocal_audit_log : « quel temps il fait / il fera », « est-ce qu'il va + # pleuvoir », « il fait combien dehors ». + "quel temps il fait", "quel temps il fera", "quel temps fait-il", + "il va pleuvoir", "il pleuvra", "il fait combien", "il fera", ) _CALENDAR_MARKERS = ( "calendrier", "agenda", "rendez-vous", "rendez vous", "rdv", "reunion", - "réunion", "demain matin", "ce soir", "planning", "evenement", "événement", + "réunion", "demain matin", "planning", "evenement", "événement", "mon emploi du temps", "qu ai-je", "qu'est-ce que j'ai", "qu est ce que j ai", + # Agenda demandé SANS le mot « agenda » (#T351) — formulations réelles du + # vocal_audit_log : « quand est-ce que je travaille », « je bosse quand », + # « c'est quand la prochaine fois que je travaille ». + "quand est-ce que je travaille", "quand est ce que je travaille", + "je travaille quand", "je bosse quand", "quand je travaille", "quand je bosse", + # Racines fiables (#T351) : couvrent « c'est quand la prochaine fois que je + # travaille » et les variantes où « quand » n'est pas collé au verbe. + "je travaille", "je bosse", + # [#T365] Le champ horaire du travail, absent de la liste et pourtant la + # formulation la plus fréquente d'Axel. Mesuré le 17/08 : sur 12 demandes + # d'agenda en 30 jours, 3 SEULEMENT atteignaient le calendrier, et + # « A quelle heure je commence à travailler demain ? » recevait « Je n'ai pas + # accès à ton agenda » alors que l'accès fonctionne. Formulations volontairement + # longues : « je commence » seul capterait « je commence à comprendre ». + # [#T383] Les deux orthographes, accentuée ET non accentuée, comme le fait + # déjà `_HA_STATE_MARKERS` (« c est allume » / « c'est allumé »). + # `_normalize_prompt` CONSERVE les accents (l. 131-132 : la classe de + # caractères gardés contient àâäéèêëïîôùûüç), donc un marqueur écrit sans + # accent ne peut pas matcher le texte réel du STT, qui en produit. Livrés + # sans accent par #T365, ces six marqueurs ne matchaient donc RIEN : + # « je commence à travailler », « je finis à quelle heure », « je termine à + # quelle heure », « je suis en congé », « quoi de prévu », « de prévu ». + # Les tests de #T365 ne l'ont pas vu : ils écrivent les phrases sans accent. + "je commence a travailler", "je commence à travailler", + "je commence le travail", "heure je commence", + "je finis a quelle heure", "je finis à quelle heure", + "je finis le travail", "quand je finis", + "je termine a quelle heure", "je termine à quelle heure", + "quand je termine", "j'embauche", "j embauche", + "jour de repos", "jours de repos", + "je suis en conge", "je suis en congé", "je suis en repos", + "quoi de prevu", "quoi de prévu", "de prevu", "de prévu", + "mon planning", "mes horaires", ) _FILES_MARKERS = ( "fichier", "document", "drive", "pdf", "dossier", "piece jointe", @@ -53,6 +91,24 @@ class VocalIntent(str, Enum): ) _INTENT_SUFFIX: dict[VocalIntent, str] = { + VocalIntent.CHAT: ( + "\n\n[INTENT=chat] Réponds en 2-3 phrases TTS. Ne JAMAIS affirmer une " + "panne technique (accès, connexion, outil en panne…) que tu n'as pas " + "constatée dans CE tour. Si aucune action n'a été tentée dans ce tour, " + "ne prétends pas qu'elle a échoué : dis honnêtement que tu n'as pas " + "d'information, ou propose une piste. " + # [#T365] Le chat ne voit pas les autres chemins du moteur et niait donc + # des capacités bien réelles : « je n'ai pas accès à ton agenda » (alors + # que le spécialiste calendrier répond), « je n'ai pas la capacité de + # contrôler le Tab 5 » (alors que 29 commandes HA ont abouti en 30 jours). + # Axel finissait par débattre des capacités du moteur avec le moteur. + "Le système auquel tu appartiens SAIT lire l'agenda Google et piloter la " + "maison (lumières, volet, climatisation) : d'autres chemins s'en chargent. " + "N'affirme donc JAMAIS que tu n'as pas accès à l'agenda ou à la domotique. " + "Si la demande relève de l'un des deux et ne t'est pas parvenue résolue, " + "invite simplement à la reformuler — par exemple « demande-moi ce que tu " + "as de prévu demain »." + ), VocalIntent.WEB: ( "\n\n[INTENT=web] Réponds en 2-3 phrases TTS. Si tu n'as pas de données " "temps réel, dis-le honnêtement et propose une piste." @@ -230,6 +286,21 @@ async def _try_zero_llm_ha_state(user_prompt: str) -> str | None: return await resolve_ha_state_query(user_prompt) +async def _try_zero_llm_ha_weather(user_prompt: str) -> str | None: + """Météo locale déterministe depuis l'entité HA de la maison. + + Intercepte les demandes météo AVANT le spécialiste web, qui n'a pas la + localisation et finissait par réclamer la position à l'utilisateur puis + livrer un chiffre inventé sur un autre continent (mesuré en prod le 17/08). + Source de vérité : l'entité `weather.*` de la maison. Retourne None si ce + n'est pas une demande météo, si la lecture HA échoue, ou si la prévision + demandée est absente → la cascade continue vers le spécialiste web. + """ + from services.ha_weather_query import resolve_weather_query + + return await resolve_weather_query(user_prompt) + + async def _process_vocal_job( job_id: str, intent: VocalIntent, @@ -330,6 +401,37 @@ async def handle_discussion( metadata=meta, ) + # 1c) Météo locale HA (avant le spécialiste web, qui n'a pas la localisation) + try: + weather_tts = await _try_zero_llm_ha_weather(user_prompt) + except Exception as exc: + logger.warning("[VOCAL_HOST] Zero-LLM météo HA échec : %s", exc) + weather_tts = None + if weather_tts: + meta["ha_zero_llm_weather"] = True + return DiscussionHostResult( + response_text=weather_tts, + agents_used=["ha_weather", "vocal_host"], + routing_type="discussion_ha_weather", + metadata=meta, + ) + + # 1d) Heure/Date déterministe + try: + from services.datetime_query import resolve_datetime_query + dt_tts = resolve_datetime_query(user_prompt) + except Exception as exc: + logger.warning("[VOCAL_HOST] Zero-LLM datetime échec : %s", exc) + dt_tts = None + if dt_tts: + meta["datetime_zero_llm"] = True + return DiscussionHostResult( + response_text=dt_tts, + agents_used=["datetime_query", "vocal_host"], + routing_type="discussion_datetime", + metadata=meta, + ) + # 2) Web / calendrier — spécialistes (grounding / OAuth), pas Cerebras tools if intent in _SYNC_SPECIALIST_INTENTS: from core.vocal_jobs import run_vocal_specialist @@ -362,13 +464,16 @@ async def handle_discussion( # 3) Chat (éventuellement lecture état HA via outils) if intent == VocalIntent.CHAT: use_tools = _looks_like_ha_state_query(user_prompt) + # [#T351] Consigne anti-panne-inventée : le modèle ne doit jamais + # affirmer un échec technique qu'il n'a pas constaté dans ce tour. + chat_suffix = system_prompt_suffix + _INTENT_SUFFIX[VocalIntent.CHAT] text = await _run_sync_discussion( user_prompt=user_prompt, session_id=session_id, gateway=gateway, token_tracker=token_tracker, fast_path_cache=fast_path_cache, - system_prompt_suffix=system_prompt_suffix, + system_prompt_suffix=chat_suffix, conversation_id=conversation_id, tier_override=tier_override, model_override=model_override, diff --git a/core/vocal_jobs.py b/core/vocal_jobs.py index 9f6e2e3..e8f54e8 100644 --- a/core/vocal_jobs.py +++ b/core/vocal_jobs.py @@ -28,7 +28,7 @@ # ── Prompts spécialistes (TTS vocal Tab5) ───────────────────────────── _WEB_SYSTEM_PROMPT = ( - "Tu es l'assistant vocal d'Axel. Réponds en français en 2 à 3 phrases courtes, " + "Tu es l'assistant vocal de la maison. Réponds en français en 2 à 3 phrases courtes, " "adaptées à une synthèse vocale. Pas de markdown, pas de listes à puces. " "Appuie-toi sur les résultats de recherche Google si disponibles." ) diff --git a/core/vocal_tools.py b/core/vocal_tools.py index 3d74089..7a0d194 100644 --- a/core/vocal_tools.py +++ b/core/vocal_tools.py @@ -10,6 +10,7 @@ import asyncio import json import logging +import time from typing import Any from core.agent_trace import etiqueter_agent @@ -411,6 +412,44 @@ def _parse_tool_args(raw: Any) -> dict[str, Any]: return {} +# [#T346] Préfixes des textes d'erreur produits par dispatch_vocal_tool() et +# ses outils. dispatch ne lève JAMAIS : il rend l'erreur en texte. Ces préfixes +# sont la seule façon de distinguer un échec d'un résultat pour le statut des +# événements de cycle de vie (ToolCallDisplay). +_PREFIXES_ERREUR_OUTIL = ( + "erreur", + "refusé", + "échec", + "entité introuvable", + "entity_id invalide", + "service_data json invalide", + "outil inconnu", + "mémoire indisponible", + "recherche web indisponible", +) + + +def _est_resultat_erreur(texte: str) -> bool: + """True si le texte rendu par un outil est un message d'erreur (#T346).""" + normalise = (texte or "").strip().lower() + return any(normalise.startswith(prefixe) for prefixe in _PREFIXES_ERREUR_OUTIL) + + +async def _notifier_evenement(on_event: Any, evenement: dict[str, Any]) -> None: + """Transmet un événement de cycle de vie s'il y a un callback (#T346). + + Sans callback (appelant non streamé historique), c'est un no-op : le + comportement de la boucle est strictement inchangé. Une défaillance du + callback ne doit JAMAIS casser la boucle d'outils. + """ + if on_event is None: + return + try: + await on_event(evenement) + except Exception as exc: + logger.debug("[VOCAL_TOOLS] callback d'événements en échec : %s", exc) + + def _assistant_message_for_history(result: dict[str, Any]) -> dict[str, Any]: """Ne garde que les champs OpenAI-compat (évite `reasoning` Cerebras rejeté).""" msg: dict[str, Any] = {"role": "assistant"} @@ -431,6 +470,7 @@ async def run_vocal_tool_loop( user_prompt: str, session_id: str, temperature: float = 0.0, + on_event: Any = None, ) -> str | None: """ Boucle tool-calling (max MAX_TOOL_ROUNDS) puis réponse texte. @@ -439,6 +479,13 @@ async def run_vocal_tool_loop( [#T308] Étiquetée : la boucle consomme hors de tout agent. Le décorateur RESTAURE l'étiquette précédente en sortant — indispensable ici, puisque cette boucle est invoquée depuis le fast-path, qui a déjà posé la sienne (#T301). + + [#T346] `on_event` : coroutine optionnelle recevant les événements de cycle + de vie des outils, au fil de l'eau — appel AVANT l'exécution + ({"type": "tool_call", "id", "toolName", "args"}) puis APRÈS + ({"type": "tool_result", "id", "toolName", "status", "resultData", + "errorMessage", "executionTimeMs"}). Absent (None), le comportement est + strictement celui de l'appelant non streamé historique. """ if not provider_supports_openai_tools(provider): return None @@ -514,7 +561,27 @@ async def run_vocal_tool_loop( args = _parse_tool_args(fn.get("arguments")) tc_id = tc.get("id") or f"call_{name}_{round_idx}" logger.info("[VOCAL_TOOLS] round=%s → %s(%s)", round_idx, name, list(args.keys())) + # [#T346] Annonce de l'appel AVANT l'exécution (statut « running » + # côté ToolCallDisplay), résultat APRÈS avec durée et statut. + await _notifier_evenement(on_event, { + "type": "tool_call", + "id": tc_id, + "toolName": name, + "args": args, + }) + debut = time.monotonic() tool_result = await dispatch_vocal_tool(name, args, session_id=session_id) + duree_ms = int((time.monotonic() - debut) * 1000) + en_erreur = _est_resultat_erreur(tool_result) + await _notifier_evenement(on_event, { + "type": "tool_result", + "id": tc_id, + "toolName": name, + "status": "error" if en_erreur else "success", + "resultData": None if en_erreur else tool_result[:2000], + "errorMessage": tool_result[:500] if en_erreur else None, + "executionTimeMs": duree_ms, + }) messages.append({ "role": "tool", "tool_call_id": tc_id, diff --git a/core/vocal_tts_cache.py b/core/vocal_tts_cache.py index ad54093..539fe24 100644 --- a/core/vocal_tts_cache.py +++ b/core/vocal_tts_cache.py @@ -21,13 +21,13 @@ _PHRASES_YAML = _REPO_ROOT / "scripts" / "domotic_phrases.yaml" _CACHE_DIRS = ( _REPO_ROOT / "00ProjetTab" / "audio_lib", - Path(r"H:\AuxFilsDesIdees\00ProjetTab\audio_lib"), - Path(r"E:\AuxFilsDesIdees\00ProjetTab\audio_lib"), + Path(r"audio_lib"), + Path(r"audio_lib"), ) # Phrases fixes du moteur (fallback si domotic_phrases.yaml absent sur le Deck) _EXTRA_PHRASES: dict[str, str] = { - "vocal_bonjour_axel": "Bonjour Axel, que veux-tu contrôler ?", + "vocal_bonjour": "Bonjour, que veux-tu contrôler ?", "vocal_merci": "De rien. Que veux-tu contrôler ?", "vocal_ha_echec": "Je n'ai pas compris la commande domotique.", "lum_salon_on": "Lumière du salon allumée.", @@ -148,6 +148,12 @@ def cache_file_for_phrase(phrase_id: str) -> Path | None: ) +# [#T366] Formules LaTeX : délimiteurs standard puis commandes résiduelles. +_RE_LATEX_BLOCS = re.compile(r"\\\[.*?\\\]|\\\(.*?\\\)|\$\$.*?\$\$", re.DOTALL) +_RE_LATEX_INLINE_DOLLAR = re.compile(r"\$[^$\n]{1,120}\$") +_RE_LATEX_COMMANDE = re.compile(r"\\[a-zA-Z]+\b\s*") + + def looks_like_tool_or_code_dump(text: str) -> bool: """True si la réponse ressemble à du JSON d'outil / code technique, pas du français TTS.""" if not text or not str(text).strip(): @@ -168,14 +174,47 @@ def looks_like_tool_or_code_dump(text: str) -> bool: return False +def _neutraliser_latex(texte: str) -> str: + """ + [#T366] Remplace les formules LaTeX par « la formule », lisible en synthèse. + + Mesuré le 17/08 : « donne-moi la formule de la relativité générale » rendait + `\\(G_{\\mu\\nu} + \\Lambda\\,g_{\\mu\\nu} = \\frac{8\\pi G}{c^{4}}\\,T_{\\mu\\nu}\\)`, + que le TTS ânonne symbole par symbole. Même défaut dans l'historique réel du + 17/08 à 09:46 — ce n'est pas un cas de test isolé. + + On remplace plutôt qu'on supprime : « L'équation s'écrit la formule, où G est + le tenseur… » reste une phrase française, là où une suppression sèche + laisserait un trou au milieu. Aucun fait n'est inventé, c'est une + transformation de présentation pour l'oral. + + Le `$…$` n'est neutralisé que s'il contient une marque de LaTeX (backslash, + accolade, exposant, indice) : « ça coûte 5 $ » ou « entre 10 $ et 20 $ » ne + doivent pas être avalés. + """ + t = _RE_LATEX_BLOCS.sub(" la formule ", texte) + + def _dollar(m: re.Match[str]) -> str: + contenu = m.group(0) + return " la formule " if any(c in contenu for c in "\\{}^_") else contenu + + t = _RE_LATEX_INLINE_DOLLAR.sub(_dollar, t) + # Commandes résiduelles hors délimiteurs (« \alpha », « \frac »). + t = _RE_LATEX_COMMANDE.sub(" ", t) + # Deux formules adjacentes ne doivent pas donner « la formule la formule ». + t = re.sub(r"(la formule)(\s+la formule)+", r"\1", t) + return t + + def sanitize_discussion_tts(text: str, max_sentences: int = 3, max_chars: int = 420) -> str: """ Nettoie une réponse LLM pour le mode Discussion vocal Tab5. - Supprime le markdown, refuse les dumps d'outils/JSON, limite la longueur TTS. + Supprime le markdown, refuse les dumps d'outils/JSON, neutralise le LaTeX, + limite la longueur TTS. """ if not text: return "" - t = str(text).strip() + t = _neutraliser_latex(str(text).strip()).strip() if looks_like_tool_or_code_dump(t): return _TOOL_DUMP_FALLBACK t = re.sub(r"```.*?```", " ", t, flags=re.DOTALL) diff --git a/core/watchdog.py b/core/watchdog.py index bae281c..fbfda12 100644 --- a/core/watchdog.py +++ b/core/watchdog.py @@ -57,7 +57,7 @@ def _build_rules() -> list[tuple[re.Pattern, re.Pattern, str]]: @dataclass class WatchdogConfig: - mqtt_host: str = "192.168.1.x" + mqtt_host: str = "192.168.1.10" mqtt_port: int = 1883 mqtt_username: str | None = None mqtt_password: str | None = None @@ -89,7 +89,7 @@ class WatchdogConfig: escalate_min_severity: str = "warning" http_timeout: float = 10.0 extra_sinks: list[Callable[[str, str, str], Any]] = field(default_factory=list) - ha_log_path: str = r"\\192.168.1.x\config\home-assistant.log" + ha_log_path: str = r"\\192.168.1.10\config\home-assistant.log" log_poll_interval: float = 14400.0 # 4 heures par défaut @@ -412,7 +412,7 @@ def read_tail(): def create_watchdog_daemon(config_dict: dict) -> WatchdogDaemon: """Factory : crée un WatchdogDaemon depuis un dict de configuration.""" cfg = WatchdogConfig( - mqtt_host=config_dict.get("mqtt_host", "192.168.1.x"), + mqtt_host=config_dict.get("mqtt_host", "192.168.1.10"), mqtt_port=int(config_dict.get("mqtt_port", 1883)), mqtt_username=config_dict.get("mqtt_username"), mqtt_password=config_dict.get("mqtt_password"), diff --git a/deploy/lancer_moteur.bat b/deploy/lancer_moteur.bat index 42d8f0c..5d497a9 100644 --- a/deploy/lancer_moteur.bat +++ b/deploy/lancer_moteur.bat @@ -1,3 +1,3 @@ @echo off -cd /d "e:\AuxFilsDesIdees\moteur_agents" -"e:\AuxFilsDesIdees\.venv\Scripts\python.exe" gui_server.py +cd /d "." +".\.venv\Scripts\python.exe" gui_server.py diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 36712f0..ffeeaf0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -10,7 +10,7 @@ > **Dernière mise à jour** : 2026-06-12 > **Version code** : V11 (Circuit Breaker V12 intégré) -> **Espace de travail** : `e:\AuxFilsDesIdees\moteur_agents` +> **Espace de travail** : `.` --- @@ -431,7 +431,7 @@ sequenceDiagram | Service | Usage | Module | |---------|-------|--------| -| LM Studio (192.168.1.x:1234) | Inférence locale (RTX 5070Ti) | `LMStudioProvider` | +| LM Studio (192.168.1.x:1234) | Inférence locale (GPU locale) | `LMStudioProvider` | | Ollama Deck (192.168.1.x/139:11434) | Inférence Edge (Steam Deck RDNA2) | `OllamaDeckProvider` | | Home Assistant (192.168.1.x:8123) | Domotique REST API | `ha_agent.py`, `tab5_pusher.py` | | Gemini API | LLM Cloud + embeddings + grounding | `gemini_native.py`, `GeminiProvider` | diff --git a/docs/architecture/carte_decision_vocale.md b/docs/architecture/carte_decision_vocale.md new file mode 100644 index 0000000..d2b0c5f --- /dev/null +++ b/docs/architecture/carte_decision_vocale.md @@ -0,0 +1,31 @@ +# Carte de la cascade de décision vocale + +Ce document cartographie l'ordre de consultation des étages dans la cascade de décision vocale du moteur. + +## Ordre de consultation + +La cascade est consultée dans l'ordre suivant : + +1. **Court-circuits Zero-LLM (Domotique)** : `services/execute_service.py` (commandes), `services/ha_state_query.py` (état), `services/ha_weather_query.py` (météo), `services/datetime_query.py` (heure/date). +2. **Spécialistes synchrones** : `core/vocal_host.py` (Web, Calendrier). +3. **Chat généraliste** : `core/vocal_host.py` (Discussion). + +## Tableau récapitulatif + +| Étage | Fichier:Fonction | Routing Type | Condition de passage au suivant | +| :--- | :--- | :--- | :--- | +| HA Commande | `execute_service.py:resolve_ha_command_for_execute` | `discussion_ha_command` | Commande non reconnue | +| HA État | `ha_state_query.py:resolve_ha_state_query` | `discussion_ha_state` | Non reconnu comme question d'état | +| HA Météo | `ha_weather_query.py:resolve_weather_query` | `discussion_ha_weather` | Non reconnu comme question météo | +| Date/Heure | `datetime_query.py:resolve_datetime_query` | `discussion_datetime` | Non reconnu comme question temporelle | +| Spécialistes | `vocal_host.py:handle_discussion` (intent) | `vocal_host_{intent}` | Intent = CHAT | +| Chat | `vocal_host.py:handle_discussion` (chat) | `discussion_chat` | N/A (dernier recours) | + +## Décision de sortie + +Une phrase est considérée comme "non pour cet étage" si : +- **Zero-LLM** : La fonction de détection (`match_*`) retourne `None`. +- **Spécialistes** : L'intention classifiée (`classify_vocal_intent`) ne correspond pas à l'intent de l'étage. +- **Chat** : C'est le dernier recours, il traite tout ce qui reste. + +*Note : Si un point n'est pas clair, il doit être considéré comme une question ouverte.* diff --git a/docs/travail/CONVENTIONS.md b/docs/travail/CONVENTIONS.md index ee983f0..2f0d88a 100644 --- a/docs/travail/CONVENTIONS.md +++ b/docs/travail/CONVENTIONS.md @@ -3,7 +3,7 @@ Seul texte collé dans l'Executor / Reviewer / ToolMaker (`conventions_projet=True`). Pas de liste MCP, pas d'archi, pas de board. - Commentaires de code en français. -- Chemins : racine canonique `H:\AuxFilsDesIdees` (E: = junction). Workspace moteur = ce dépôt. +- Chemins : racine canonique `.` (E: = junction). Workspace moteur = ce dépôt. - Windows : pas de `ls`/`grep`/`cat` via le terminal — `read_file` / `write_file` / `dir` / `findstr`. - Linux (Deck) : l'inverse — pas de `dir`/`findstr`/`type`. - Secrets : jamais en clair. HA/ESPHome → `!secret`. Python → env. diff --git a/docs/travail/DECISIONS.md b/docs/travail/DECISIONS.md index 50998f4..a0e699e 100644 --- a/docs/travail/DECISIONS.md +++ b/docs/travail/DECISIONS.md @@ -22,13 +22,37 @@ Cycle signaler → désactiver → réactiver pour la **validité**. Soldes/quot **07/2026** · Axel Présent nulle part dans les tiers. Appel explicite seulement. `routing_policy.excluded_models`. +### D-5 — OpenRouter = `openrouter/auto` seulement +**13/08/2026** · #T331 · décision Axel +Clé saine (14,99 $) mais slugs Llama `:free` en 404. Les 9 autres modèles catalogue n'étaient pas câblés. +**Choix :** un seul modèle gateway, `openrouter/auto`. Slugs `:free` exclus de la cascade. + +### D-6 — Claude API native hors cascade, clé conservée +**13/08/2026** · #T331 · décision Axel +`ANTHROPIC_API_KEY` présente, crédit à sec (HTTP 400 sur Haiku/Sonnet/Opus). +**Choix :** exclure `claude-sonnet-5`, `claude-opus-4-8-direct`, `claude-haiku-4-5-direct`. Accès explicite possible après recharge. + +### D-7 — CLI : installer sur le Deck +**13/08/2026** · #T331 · décision Axel +`claude` présent sur Windows, absent du Deck (hôte + ubuntu-dev). `gemini` CLI nulle part. +**Choix :** installer / reconnecter les binaires dans le conteneur Deck (login Axel). + +### D-8 — Dashscope désactivé +**13/08/2026** · #T331 · décision Axel +Clé présente (Windows + Deck) mais `POST /chat/completions` en 401 ; CGU Alibaba interdisent le backend auto. +**Choix :** désactiver. Catalogue + clé conservés. `DASHSCOPE_ACTIF = False` : pas d'instance gateway, hors cascade (préfixe), hors BudgetGuard, règles de routage repliées. + +### D-9 — Verrou d'exécution par session (#T324) +**16/08/2026** · #T324 +L'ancien verrou **global** sérialisait toutes les exécutions (IHM, vocal, DAG) : une commande vocale Tab5 → HTTP 409 sur tout le reste. Mesuré 16/08 : 28 % des exécutions > 10 s, 15 % > 1 min, pendant lesquelles les trois portes (`/api/run`, `/api/execute`, `/api/execute/stream`) renvoyaient 409. +**Choix :** granularité = **session**. Clé d'exécution = `session_id` fourni par le client, sinon la source de la requête (device_id, sinon type:mode) — le Tab5 qui rejoue une commande est bloqué en doublon. Plafond global `MOTEUR_EXECUTION_MAX_CONCURRENCY` (défaut 3) pour ne pas saturer le Deck ; au-delà, 409 distinct du doublon. `execution_state` reste une vue agrégée (4 clés) : `running` dès qu'une exécution tourne → aucun consommateur (status, stop, abort, Dreamer, pipeline) cassé, aucun changement IHM. + ## En attente d'Axel | Id | Question | Options | Bloque | |---|---|---|---| | #T315 | Seuil HITL (`medium` = presque tout plan) | relever à `high` / restreindre les mots-clés / garder | 3/8 demandes campagne | | #T335 | Provider CLI dans le tier `fort` ? | retirer / plafonner le contexte / garder | coût $0,67 / appel Opus CLI | -| #T331 | Clé Dashscope morte + slug OpenRouter retiré | renouveler / désactiver | 35 % des tentatives cascade | | #T326 | `MOTEUR_API_KEY` locale ≠ prod | unifier / documenter | 401 croisés | | #T327 | Second moteur `vromvrom-engine` :8002 sur le Deck | arrêter / assumer | bruit daemon | | #T223 | TLS HA (`HA_VERIFY_TLS` / CA) | bundle / hostname / `false` LAN | — | diff --git a/docs/travail/TACHES.md b/docs/travail/TACHES.md index 39821c7..3f0e50b 100644 --- a/docs/travail/TACHES.md +++ b/docs/travail/TACHES.md @@ -6,48 +6,83 @@ **Statuts** : ⏳ todo · 🔄 wip · 🧪 test · ✅ done · 🧊 gelé -## 📍 13/08/2026 (01h10) +## 📍 18/08/2026 (matin — état vérifié, pas recopié du board) | | | |---|---| -| Dépôt | `origin/master` @ `2e2ea69`. **0 PR ouverte** | -| Prod | **à jour** : déployée le 13/08 **01:04** sur `2e2ea69` (`deploy_deck.sh`, rollback `deploy/pre-20260813-010408`). `/version` = `2e2ea69`, `/healthz` 200, **0 ERROR** au journal. Clos cette nuit : #T341 #T330 #T334 #T306 #T325 #T340 #T342 (PRs #292 #294 #295 #296 #293 #290 #291). | -| Décisions Axel | [DECISIONS.md](DECISIONS.md) · #T315 #T335 #T331 #T326 #T327 #T223 #T260 | +| ⚠️ **Deux boards ont divergé le 17/08 au soir** | Deux sessions parallèles ont écrit deux `TACHES.md` différents. Celui-ci (PR #348) avait **perdu** #T370→#T378 et #T388/#T389 ; la branche `claude/moteur-prompts-parallele-20ae55` (`ada7ab7`) les portait mais ignorait #T383/#T385. **Ce board-ci est la réconciliation des deux** : statut de chaque tâche re-vérifié contre `origin/master`, branche par branche, et non d'après les numéros #T (qui ne figurent pas toujours dans les messages de commit). | +| ✅ Prod déployée | `/healthz` → `c30e194`. Le retard annoncé la veille n'existe plus. | +| ✅ **AJEAN est joignable — le blocage de #T353 est levé** | Mesuré depuis le Deck : `192.168.1.x:8080/v1/models` → **HTTP 200**. Le réglage `--host 0.0.0.0` + pare-feu attendu d'Axel **est fait**. ⚠️ Mais `/v1/models` ne prouve pas l'inférence (piège DashScope) : la complétion réelle depuis le Deck **reste à mesurer** avant toute bascule → tâche 1 de la **23ᵉ série**. | +| 🚨 **`ollama_pc` est mort et toujours en tête de cascade** | `192.168.1.x:11434` → **000 depuis le Deck ET depuis le PC lui-même** : Ollama n'est plus lancé sur le PC, AJEAN l'a remplacé. Or `FAST_PATH_PROVIDERS` (`services/pipeline_service.py:81-87`) le garde en 2ᵉ position. La sonde d'hôte de #T360 l'écarte avant l'appel (le coût de 2,6 s est donc amorti), mais **le tier local n'a plus aucun maillon côté PC**. `ollama_local` sur le Deck (127.0.0.1:11434) répond 200 : c'est le seul local vivant → #T354 reste bloqué. | +| 🚨 CI toujours morte (#T382) | Quota Actions atteint. **La suite locale est la seule preuve.** Relancée ce matin sur la branche de la PR #348 : **1858 passés, 0 échec** ; puis sur #T371 : **1869 passés, 0 échec** (Python 3.13). | +| Livré le 18/08 | **#T371** (PR #350) — la température ne vient plus d'une autre pièce. **23ᵉ série** de délégation écrite (bascule AJEAN, mesure avant code). | +| 🎯 Prochaine action | **Déployer le Deck sur `be696ee`** (#348 mergée à 07:29 UTC ; #T385 P0 et #T383 P1 attendent dans la maison), puis merger la PR #350 (#T371) et celle-ci — `master` protégée + CI morte → override interface, **clic d'Axel**. Vérification en prod : rejouer 4 phrases réelles — un volet (#T385), un agenda **accentué** (#T383), une question d'état (#T363), une température de **buanderie** et d'**extérieur** (#T371). | -## 🔄 En cours — campagne 12-13/08 +## 📍 17/08/2026 (23h30 — après la vague de merges du soir) + +| | | +|---|---| +| Dépôt | `origin/master` @ `c30e194`. **Le merge GitHub est redevenu opérationnel** : les PR **#326 à #347** ont toutes été mergées entre 18h33 et 23h22, y compris les 7 branches que ce board donnait « en attente ». **0 PR ouverte**, `master` local = `origin/master`, arbre propre. | +| ⚠️ Prod | `/healthz` → `c30e194` (vérifié le 18/08 au matin) : le retard de 22 PR annoncé la veille **n'existait plus**, #T363 (P0) est en service. **Mais la PR #348 a été mergée depuis** (`be696ee`), donc le Deck est de nouveau en retard d'un cran — et ce cran porte **#T385 (P0, la commande de volet refusée à tort)** et **#T383 (P1, l'agenda sourd aux accents)**. Déploiement requis. | +| ✅ Vérification du 17/08 (23h30) | Suite complète relancée sur `c30e194` : **1839 tests collectés, 0 échec, 7 ignorés** (Python 3.12). 31 fichiers de tests ajoutés dans la journée. `tsc -b` et `vite build` au vert. **C'est la seule preuve verte de la journée** — voir la ligne CI. | +| 🚨 **CI GitHub Actions morte — #T382** | **Cause confirmée par Axel le 17/08 : la limite d'utilisation Actions est atteinte.** Les runs échouent en **4 s**, sans log récupérable (404), et même `🔍 Ruff (informatif)` tombe alors que toutes ses étapes sont `continue-on-error: true` — l'échec est avant le `checkout`, donc jamais du code. Conséquence assumée : **les 22 PR du soir ont été mergées sans preuve CI**. Tant que le quota n'est pas relevé, **la seule barrière qualité est l'exécution locale** (`pytest tests/unit` + `npm run build`) — un ✅ GitHub ne veut rien dire. | +| ⚠️ Ce que ce trou a laissé passer | **#T383** : #T365 (PR #328) est parti en prod **à moitié mort**. Six formulations accentuées réelles ne matchaient aucun marqueur, parce que `_normalize_prompt` **conserve** les accents et que les marqueurs avaient été écrits sans. Les tests de #T365 ne l'ont pas vu : ils écrivent les phrases **sans accent**. Trouvé le 17/08 à 23h50 en rejouant un banc de régression resté sur une branche de sauvegarde. | +| 🎯 Prochaine action | **Déployer le Deck.** Les deux régressions qui l'auraient pourri sont corrigées (#T383, #T385). Ordre : (1) merger la PR en cours — `master` est protégée et la CI est morte, donc **override interface obligatoire** (#T382) ; (2) `git pull` sur `origin/master` ; (3) redémarrer le service ; (4) vérifier `/healthz` → `{"commit":"…"}` (#T355) ; (5) **rejouer 3 phrases réelles** : une commande de volet (#T385), une demande d'agenda **accentuée** (#T383), une question d'état (#T363). | +| Clos le 17/08 — journée | #T343 (#301-303), #T324 (#305), #T316 (#300), #T344-a (#304), #T289 (#307), #T290 (#308), #T337 (#309), #T345 (#311), #T344 (#312), #T169 (#313), #T347 (#315), #T348 (#316), #T349 (#317), #T350 (#318), #T351 (#319), #T352 (#320), #T355 (#321), #T356 (#323) ; #T303 et #T307 **étaient déjà livrés** — voir archive | +| Clos le 17/08 — soirée | #T358 (#325), #T364 (#326), **#T363 (#327, P0)**, #T365 (#328), #T366 (#329), #T357 (#330), #T367 (#332, couvre #T359), #T362 (#333), #T360 (#324 #334), #T369 (#335), #T346 (#336), #T305-a (#337), #T353-a (#339), #T361 (#343), #T379 (#344), #T373 #T380 (#345) — voir archive | +| Mesures du 17/08 (résumé — détail en archive) | **Prod** : 943 échanges vocaux, 499 sessions — le vocal est vivant et utilisé. **Balayage** : 458 réponses rendues, 14 % problématiques ; les 3 causes classées (#T350 #T351 #T352) et les 4 défauts du rejeu de 36 phrases (#T363 #T364 #T365 #T366) sont **tous corrigés et mergés**. **Sessions zombies** : pas une panne, statut de ramassage ; seul défaut réel (#T361) corrigé. ⚠️ Le chemin `bg`/DAG (52 % de sinistre contre 7 % en `chat`) **n'a toujours pas été rejoué** — verdict non prouvé. **Coût** : les 11,28 $ du matin étaient un chiffre faux mesuré sur la base LOCALE (#T347 #T348 #T349, corrigés). | +| ⚠️ Fiabilité des mesures d'historique | **73 % des latences ne sont pas enregistrées** : sur 165 réponses réelles en 30 j, seules 45 portent une `latency_ms > 0` (120 à 0), surtout sur `discussion_chat` (94/142 muettes). Toute conclusion de performance tirée de l'historique porte donc sur **un quart des échanges**. Les mesures prises en exerçant la prod, elles, sont complètes — c'est la voie fiable. | +| ⚠️ Piège de mesure | Le trafic vocal réel est **`mode=chat` : 166 échanges sur 169 en 30 j**. `mode=ha` n'en porte que 12, et ses 4 refus `ha_mode_failure` sont des phrases STT cassées (« décembre le volet de la sœur »). **Tester en `mode=ha` ne reproduit pas l'usage** — un premier passage y avait conclu à tort que la météo était refusée. | +| Délégation | **Recompté le 18/08 dans `prompts_delegation.md` : 22 séries écrites, et les séries 17 à 22 sont livrées** (elles manquaient à ce board — voir la note des chantiers). **Un seul prompt déjà écrit n'a jamais produit de code : #T368** (banc de non-régression, 19ᵉ série). **23ᵉ série écrite le 18/08** : bascule AJEAN, en deux temps — mesurer une complétion réelle depuis le Deck AVANT de toucher à la cascade. Restent à écrire : **#T381** (canal raisonnement/outils sur le pipeline complet — fichier chaud `core/engine.py`, PAS délégable) et **#T354** (retrait d'Ollama, toujours bloqué). | +| Décisions Axel | [DECISIONS.md](DECISIONS.md) · #T315 #T335 #T326 #T327 #T223 #T260 | + +## 🔄 Décisions en attente (campagne 12-13/08) -- ⏳ #T343 — agents — **P3** — Résidus Bugbot post-merge : (a) JSON d'arguments malformé → fausse dédup #T341 ; (b) hystérésis de sortie non consécutive + notify HA échouée fige `en_alerte` #T334 ; (c) chemin streaming `/v1` sans `agent_courant("proxy_v1")` #T306. — Cursor — -- ⏳ #T331 — providers — **P2** — **35 % des tentatives de cascade perdues.** Confirmé 13/08 : `dashscope/qwen3-coder-next` a encore échoué avant repli `gemma-4-31b`. Clé Dashscope morte ; slug OpenRouter gratuit retiré ; binaires `claude`/`gemini` absents du conteneur prod. **Axel** : renouveler ou désactiver — ne pas le faire en silence. — Claude — lié #T243, #T314 — -- ⏳ #T324 — api — **P2** — Verrou d'exécution **global** : une commande vocale Tab5 → HTTP 409 sur tout le reste (jusqu'à 65 s). Arbitrer : verrou par session/source. — Claude — - ⏳ #T326 — config — **P3** — **DÉCISION AXEL** — `MOTEUR_API_KEY` 29 car. local vs 64 prod → 401 croisés. Unifier ou documenter. — Claude — - ⏳ #T327 — déploiement — **P3** — **DÉCISION AXEL** — second moteur `vromvrom-engine` :8002 sur le Deck depuis le 03/07 (daemon 10 min, 2 anomalies/cycle). Arrêter ou assumer. — Claude — - ⏳ #T315 — hitl — **P2** — **DÉCISION AXEL** — HITL dès `medium` (« écrire/créer/exécuter/terminal ») : 3/8 demandes campagne mortes, dont un CSV et un `ls` de `docs/`. — **Axel** — - ⏳ #T335 — routage — **P3** — **DÉCISION AXEL** — CLI dans le tier `fort` ? Un appel `claude-opus-4-8` = 289 966 tokens in / $0,6665 = 100 % du coût campagne. En prod les binaires sont absents (#T331) donc ça échoue au lieu de coûter. — **Axel** — suite #T314 — -- ⏳ #T316 — routeur — **P3** — Parseur classifieur **sain** (énoncé corrigé 12/08). Reste : complétion comptée 40 000 tokens pour 348 in sur `gemma-4-31b`. — Claude/DeepSeek — ## IHM v2 (câblage, composants déjà mergés) -- ⏳ #T289 — ihm-v2 — **P2** — Câbler `CustomModelModal` dans `LLMRegistry.tsx` (vit dans `openfox_prep/`, jamais importé). — Claude — -- ⏳ #T290 — ihm-v2 — **P2** — Câbler `ToolPermissionSelector` dans `AgentsManager.tsx` (`allowed_tools`, #T233). — Claude — dépend #T233 — -- ⏳ #T291 — ihm-v2 — **P2** — Câbler `ThinkingBlock` + `ToolCallDisplay` dans `Chat.tsx`. — Claude — lié #T304 — -- ⏳ #T304 — ihm-v2 — **P2** — SSE `/api/stream/chat` mot-à-mot (alimente `ThinkingBlock`). — Claude — -- ⏳ #T305 — ihm-v2 — **P3** — `TaskKanbanBoard.tsx` dans `WorkflowBuilder.tsx`. — Claude — +- 🧪 #T291 — ihm-v2 — **P2** — **CÂBLÉ le 17/08 au soir**, et l'énoncé était incomplet. `ThinkingBlock` + `ToolCallDisplay` sont montés dans `Chat.tsx`, alimentés par `chatStore` (`thinkingText`/`thinkingStreaming`/`thinkingDurationMs`/`toolCalls`) depuis les événements livrés par #T346. Trois précisions issues du travail : (a) **un canal d'étapes était mort** — `chat.ts:73` n'écoutait que `orchestration_step`, un nom que **rien n'émet côté Python** (vérifié par grep sur tout le dépôt) alors que le backend envoie `agent_started`/`agent_completed`/`phase_changed` (`core/engine.py:370,394,1127`) : les badges d'étapes de la vue Chat **et** du Prompt Studio ne se sont jamais affichés ; (b) `tool_result` ne reporte pas les `args`, d'où un type `ToolCallResult` distinct pour que la fusion n'efface pas ceux de l'annonce ; (c) le type `ToolCallData` descend dans `state/chatStore` (source unique), le composant l'importe. `tsc -b` et `vite build` au vert. — Claude — +- ⏳ #T381 — archi/ihm-v2 — **P2** — **Le raisonnement et les outils n'atteignent pas le pipeline complet.** #T346 émet `thinking`/`thinking_done`/`tool_call`/`tool_result`, mais **uniquement depuis `stream_discussion_fast_path_sse`**, c'est-à-dire la branche `source.mode = "chat"` (`api/routes/streaming.py:113,135`). Or la vue Chat envoie `mode: "default"` (`Chat.tsx:195`, idem `PromptStudio.tsx:106`) et part donc dans `run_full_pipeline`, dont les seuls événements sont `agent_started`/`agent_completed`/`phase_changed`/`plan_waiting_approval`… — aucun canal de raisonnement, aucun appel d'outil, alors que c'est l'Executor qui en fait le plus. Les blocs câblés par #T291 restent donc éteints dans l'usage réel de l'IHM. Plomber le canal depuis `Engine.on_event` jusqu'au SSE. ⚠️ Fichier chaud `core/engine.py` → PR dédiée (PLAN.md). — Claude — débloque l'usage réel de #T291 — +- ⏳ #T305 — ihm-v2 — **P3** — **Créer** `TaskKanbanBoard` (il n'existe pas — ce n'était pas un câblage). **Requalifié et spécifié le 17/08** : périmètre et maquette dans `docs/specs_ihm_v2/T305_TaskKanbanBoard_Spec.md` (#T305-a, PR #337). Prêt à planifier. — Claude — ## Chantiers code -- ⏳ #T303 — refactoring — **P2** — Extraire le bloc map-reduce de `dag_runner.py` (~400 lignes) vers `core/dag/`. Extraction PURE (preuve AST). — DeepSeek — -- ⏳ #T307 — mémoire — **P3** — Hook `sync_db_to_markdown.py` sur « ENREGISTRE ». **Ajout seulement** — jamais réécrire `04_Projets`. — DeepSeek — -- ⏳ #T169 — gateway — **P3** — Timeout 120 s + routage `zai-glm-4.7` → Cerebras. **Non reproduits.** Verdict par symptôme, réfutation comprise. — DeepSeek — +> **Tâches récupérées du board divergent, statut re-vérifié le 18/08 contre `origin/master`.** +> Mergées et vérifiées présentes dans le code (donc **closes**, ne pas les relancer) : **#T374** +> (contrat plancher, `fix/contrat-plancher-jugement-17-08`), **#T375** (backoff de la sonde, +> `agent/backoff-sonde-modeles`), **#T376** (hygiène du journal, `fix/hygiene-journal-tls-gemini`), +> **#T377**+**#T380** (watchdog configurable, PR #347), **#T378** (zone grise du catalogue, +> `fix/zone-grise-catalogue-routage`), **#T379** (clôture de session, PR #344), **#T388**+**#T389** +> (`docs/architecture/carte_decision_vocale.md` et `tests/unit/test_ordre_cascade_vocale.py`, +> tous deux présents). ➜ voir archive. + +- ✅ #T370 — vocal — **P1** — **DOUBLON DE #T383, aucun travail à lancer.** Les 8 marqueurs signalés morts faute d'accents (`reunion`, `evenement`, `je commence a travailler`, `je finis a quelle heure`, `je termine a quelle heure`, `je suis en conge`, `quoi de prevu`, `de prevu`) sont **tous** présents dans les deux orthographes (PR #348, mergée le 18/08) (`core/vocal_host.py:47,72-80`, vérifié le 18/08). Se clôt avec #T383. — voir archive — +- 🧪 #T371 — domotique — **P1** — **CORRIGÉ le 18/08, PR #350.** Reproduit sur le code de `master` avant d'écrire : « température de la buanderie » **et** « température extérieure » renvoyaient toutes deux le capteur du **salon** — valeur exacte, donc indiscernable d'une bonne réponse. Trois causes distinctes : (a) le repli `_TEMPERATURE_DEFAULT_ROOM` s'appliquait même quand la phrase nommait un lieu ; (b) l'étage d'état passe **avant** l'étage météo dans la cascade, donc #T367 n'était jamais atteint pour l'extérieur — et une fois relâché, `ha_weather_query` ne le reconnaissait pas non plus (marqueurs ajoutés dans les **deux** orthographes, ce module conservant les accents) ; (c) **régression de #T364** : « la température de la chambre » ne répondait plus RIEN alors que `sensor.bedroom_temperature` est vivant (**24,49 °C** relevé sur le HA de prod le 18/08) — la réponse avait été supprimée au lieu que le capteur soit branché. Le test de #T364 qui figeait ce comportement dégradé est actualisé, pas affaibli. 11 tests neufs ; suite complète **1869 passés, 0 échec**. — Claude — +- ⏳ #T372 — domotique — **P2** — **DÉCISION AXEL puis code — l'état du volet vient d'un miroir, pas de l'appareil.** « est-ce que le volet est ouvert ? » → « Le volet est partiellement ouvert. » Le moteur lit `input_text.blind_state` (`services/ha_state_query.py`), qui vaut « Partiel » — la réponse est fidèle à sa source. Mais l'entité matérielle `cover.living_room_blind` est à `unknown` (volet sans capteur de position, cf. #T385). **Les deux divergent, et l'utilisateur reçoit une position affirmative alors que l'appareil n'en rapporte aucune.** À trancher avant de coder : le miroir fait-il foi (il sait ce qui a été commandé) ou l'entité (elle sait ce qui est) ? Et que répondre quand ils se contredisent ? — **Axel** puis Claude — +- 🧪 #T368 — tests — **P2** — **LIVRÉ le 18/08 à 07:46 (PR #349)**, après deux échecs (moteur : faux succès sans fichier — l'incident qui a produit #T374 ; puis réassignation). ⚠️ **Il contredit #T371 sur une ligne, et c'est #T368 qui a tort** : le banc fige « il fait combien dans la chambre ? » → aucune lecture d'état, au motif que la chambre serait une pièce **sans capteur**. Or `sensor.bedroom_temperature` répond **24,49 °C** sur le HA de prod (relevé le 18/08). Vérifié en fusionnant les deux branches en local : **1 cas sur 36 échoue**, `test_chaque_phrase_prend_la_bonne_decision[#T364 | il fait combien dans la chambre ?]`. **Ordre de merge : #349 d'abord, puis #350 rebasée** — la ligne du banc passe alors à `"temperature"` attendu. Ne PAS merger #350 en premier : le banc tomberait rouge sur `master` et la CI est morte pour le dire (#T382). — **DeepSeek** — + + +- 🧪 #T385 — domotique — **P0** — **CORRIGÉ le 17/08, décision Axel, avant déploiement.** #T356 allait casser la commande vocale la plus utilisée : le code mergé traitait `unknown` **exactement comme** `unavailable` (`services/execute_service.py`), et `_entite_physique_a_verifier` redirige le script de volet vers `cover.living_room_blind`. Or ce volet — **le seul de l'installation** — est en `unknown` **et fonctionne** (mesure HA du 17/08 restée sur `origin/docs/board-lot-13-deepseek`, `358ebde` ; script relancé avec succès à 13:53) : c'est un volet **sans capteur de position**, `unknown` est son état normal. Sans ce correctif, chaque commande de volet aurait répondu « le volet de la serre ne répond pas » **pendant que le volet bouge**. Correctif volontairement **étroit** : l'exception ne couvre que le domaine `cover` **sans** attribut `current_position` ; `unavailable` bloque toujours, une `light` en `unknown` bloque toujours, et un volet qui SAIT rendre sa position reste suspect en `unknown`. 6 tests, dont le chemin réel via le script. ⚠️ **À re-vérifier en prod après déploiement** : rejouer une commande de volet et confirmer qu'elle n'est pas refusée. — Claude — +- 🧪 #T383 — vocal — **P1** — **CORRIGÉ le 17/08 à 23h55, avant déploiement.** #T365 était **à moitié mort en prod** : `_normalize_prompt` (`core/vocal_host.py:131-132`) conserve les accents — sa classe de caractères gardés contient explicitement `àâäéèêëïîôùûüç` — or les marqueurs du champ horaire avaient été écrits **sans** accent. Mesuré : « je commence à travailler », « je finis à quelle heure », « je termine à quelle heure », « je suis en congé », « quoi de prévu », « de prévu » ⇒ **zéro marqueur touché**, retour au chat. Le défaut a franchi le merge parce que les tests de #T365 écrivent leurs phrases sans accent (`tests/unit/test_vocal_host.py:331,334`) : ils validaient une entrée que le STT ne produit pas. Correctif : les **deux** orthographes listées, comme le fait déjà `_HA_STATE_MARKERS` (« c est allume » / « c'est allumé »). 12 tests neufs, dont un qui fige la cause racine (si la normalisation se met un jour à dépouiller les accents, il tombe au lieu de laisser pourrir les doublons). — Claude — +- ⏳ #T384-a — hygiène — **P3** — **Supprimer `origin/fix/dag-watchdog-configurable`** (décision Axel 17/08) : doublon, c'est la variante `feat/` qui a été mergée en PR #347. **Non faisable depuis la session cloud** — le proxy git refuse la suppression de ref (`send-pack: unexpected disconnect`, 4 tentatives) et l'API GitHub exposée n'a pas d'outil de suppression de branche. À faire depuis le PC : `git push origin --delete fix/dag-watchdog-configurable`, ou en un clic dans la liste des branches GitHub. — Axel — +- ⏳ #T384 — hygiène — **P3** — **Travail resté sur `origin/backup/wip-soir-2026-08-17`, à trier.** Le code du soir est bien mergé (vérifié fichier par fichier), mais 7 fichiers ne sont nulle part ailleurs : `scripts/verify_workspace.py` (300 l.), `scripts/worktree_manager.py` (120 l.), `scripts/review_changes.py`, `scripts/test_dag_parallelism.py`, `scripts/test_workflow_deepseek.py`, `docs/travail/AUDIT_IHM_V2.md`, et `api/routes/context_fix.py` (**doublon de brouillon, à jeter**). Le banc `tests/unit/test_regression_prod_17_08.py` de cette branche a servi à trouver #T383 mais **sa détection calendrier est fausse** (comparaison sur `phrase.lower()` sans passer par le classifieur) : à réécrire sur `classify_vocal_intent` avant de le reprendre. ⚠️ Sa copie de `test_bulk_status_libere_sonde.py` est **périmée** — `master` porte la version corrigée (`da5a0b0`). — Claude — +- ⏳ #T382 — ci — **P1** — **La CI ne tourne plus et personne ne l'a vu.** Les 10 derniers runs de `ci.yml` échouent, `master` compris ; les 22 PR du 17/08 au soir ont donc été mergées **sans aucune preuve CI**. Diagnostic posé le 17/08 à 23h45 : run de **4 s** pour 3 jobs, **logs en 404**, et le job Ruff échoue alors que ses étapes sont toutes `continue-on-error` → l'échec est **avant le `checkout`**, ce n'est pas du code. **Cause confirmée par Axel : limite d'utilisation Actions atteinte.** Reste à trancher : relever le quota, ou basculer la CI sur un runner auto-hébergé (le Deck tourne déjà 24/7). **Action Axel.** Garde-fou en attendant : exécuter la suite en local avant tout merge (`pytest tests/unit` + `npm run build`), et ne pas lire un ✅ GitHub comme une preuve. — Axel/Claude — + +- ⏳ #T353 — runtime — **P2** — **DÉCISION AXEL 17/08 : abandon d'Ollama au profit d'AJEAN** (gestionnaire llama.cpp d'Axel, **pas** Jan/jan.ai), sur le PC ET sur le Deck. Câbler deux providers (`ajean_pc` 192.168.1.x, `ajean_deck` 127.0.0.1) et les mettre en tête du tier local. **MAJ 17/08 soir — AJEAN EXISTE ET TOURNE** : `C:\Users\axell\ajean\llamacpp-vulkan\llama-server.exe` (PID 38816, build llama.cpp `b10451`), modèle `Qwen2.5-14B-Instruct-1M-Q4_K_M.gguf`, `-ngl 999 -c 32768 -ctk/-ctv q8_0`, lancé au logon par `C:\ProgramData\ajean\bin\ajean.exe start`. **Complétion réelle obtenue** sur `POST /v1/chat/completions` (pas seulement `/v1/models`), avec bloc `usage` réel. ⚠️ **Bloqué par un seul réglage : `--host 127.0.0.1`** → depuis le Deck, `192.168.1.x:8080` est injoignable (testé). **Action Axel : faire écouter AJEAN sur `0.0.0.0` + ouvrir le pare-feu Windows sur le LAN.** Le câblage déclaratif est délégué sans attendre → **#T353-a** (16ᵉ série). Trace périmée : le Deck a bien un `127.0.0.1:1337` qui répond `Forbidden`, service non identifié, sans rapport avec AJEAN. Prérequis Axel : serveur d'API Jan activé, écoute sur `0.0.0.0`, pare-feu Windows ouvert sur le LAN — mêmes prérequis que ceux déjà documentés pour `ollama_pc` (`core/openai_compat_provider.py:697-700`). **Bonne nouvelle pour #T337** : `_est_hote_local` (`core/llm_gateway.py:820-832`) accepte déjà toute IP privée, donc Jan sera reconnu local sans modification — **à condition de le déclarer par IP et jamais par nom DNS** (l. 830-831 : un nom d'hôte est traité comme externe). — Claude puis DeepSeek — bloque #T354 — +- ⏳ #T354 — runtime — **P2** — **Retrait complet d'Ollama** (provider `ollama_local` + `ollama_pc`, descripteur `OllamaLocalPlugin` `core/llm/builtin_providers.py:327`, entrées `core/openai_compat_provider.py:689-706`, cascade `services/pipeline_service.py:82`, modèles du catalogue). ⚠️ **NE PAS FAIRE AVANT #T353 vérifié** : mesuré le 17/08, `127.0.0.1:11434` répond **HTTP 200 sur le Deck** avec 3 modèles (`qwen2.5-coder:1.5b`, `phi3:mini`, `gemma2:2b`) — c'est le **seul runtime local qui répond en production aujourd'hui**. Le retirer avant que Jan ne réponde ouvrirait une fenêtre sans aucun local : `privacy_level=local_only` (#T337) deviendrait fail-closed permanent, et le fast-path vocal perdrait son premier maillon. — DeepSeek — dépend #T353 — - ⏳ #T282 — cli — **P2** — Trust workspace Claude CLI : `hasTrustDialogAccepted: true` dans `~\.claude.json`. — Antigravity — - ⏳ #T179 — scraper — **P2** — `billing_scraper.js` Anthropic + Gemini abo. Valider `--headless=false`. — Claude — - 🧊 #T183 — catalogue — **P3** — 4 modèles Cohere payants non câblés (gelé Axel). — Claude — -- ⏳ #T184 — billing — **P3** — Cerebras jamais testé en direct (Chrome + API solde). — Claude — +- ⏳ #T184 — billing — **P3** — Cerebras jamais testé en direct (Chrome + API solde). **NB** : les 2 clés répondaient 403 le 16/08. — Claude — - ⏳ #T144 — infra — **P3** — CRLF dans `.env` du Deck. — Axel/Claude — - ⏳ #T191 — mémoire — **P3** — Agent d'expansion sémantique de la mémoire active. — Claude — ## À voir (produit, pas le hot-path) - ⏳ #T336 — auth — **P3** — Multi-tenant + API keys RBAC. — Claude — -- ⏳ #T337 — routing — **P2** — `privacy_level: local_only` (inférence locale forcée). — DeepSeek — - ⏳ #T338 — ihm-v2 — **P3** — Dashboard multi-bâtiments. — Claude — - ⏳ #T339 — b2b — **P3** — Pack « VromVrom Box » hôtellerie/serres. — Axel — diff --git a/docs/travail/TACHES_archive.md b/docs/travail/TACHES_archive.md index ac20cb1..8757ea5 100644 --- a/docs/travail/TACHES_archive.md +++ b/docs/travail/TACHES_archive.md @@ -3,7 +3,7 @@ Ne pas lire au boot d'une session. Consulter seulement pour « est-ce que #Txxx est déjà clos ? » Clôtures **antérieures au 12/08/2026** (158 tâches, board commun) : -`H:\AuxFilsDesIdees\contexte_ia\04_Projets\TACHES_terminees.md` +`.\contexte_ia\04_Projets\TACHES_terminees.md` À partir de #T340, les clôtures **moteur** s'archivent ici, dans la même PR que le fix. @@ -11,8 +11,57 @@ Clôtures **antérieures au 12/08/2026** (158 tâches, board commun) : `- ✅ #Tid — zone — date — PR #n — une ligne de preuve` +## 17/08/2026 — soirée (vague de merges #325 → #347) + +> Vérifié le 17/08 à 23h30 sur `c30e194` : suite complète **1839 tests, 0 échec, 7 ignorés**. + +- ✅ #T363 — domotique — 17/08 — PR #327 — **P0** — une question n'actionne plus la maison. Cause profonde nommée dans le fix : `normalize_vocal_stt` réécrit « est-elle » en « éteins » (`core/vocal_stt_normalize.py:38`), donc le garde-fou juge le **prompt brut**, pas le texte normalisé +- ✅ #T364 — domotique — 17/08 — PR #326 — le résolveur d'état ne devine plus : « combien » ne vaut marqueur de température que s'il est ancré par une pièce connue, et la branche lumière exige le mot de domaine en plus de la pièce +- ✅ #T358 — vocal — 17/08 — PR #325 — une définition (« c'est quoi un volet roulant ? ») n'est plus lue comme un état d'appareil +- ✅ #T365 — vocal — 17/08 — PR #328 — le champ horaire du travail atteint le calendrier ; la consigne CHAT interdit d'affirmer l'absence d'accès agenda/domotique +- ✅ #T366 — vocal/tts — 17/08 — PR #329 — le LaTeX ne part plus à la synthèse, les 4 appelants couverts d'un coup par `sanitize_discussion_tts` +- ✅ #T357 — proxy — 17/08 — PR #330 — le champ `usage` ne porte que des compteurs réels, et disparaît sinon +- ✅ #T362 — proxy/gateway — 17/08 — PR #333 — l'usage réel capturé par le provider remonte jusqu'au client `/v1` au lieu d'être jeté +- ✅ #T367 — vocal/météo — 17/08 — PR #332 — météo répondue depuis l'entité `weather.*` de la maison (`services/ha_weather_query.py`, 333 lignes). **Couvre #T359, qui est clos sans avoir été lancé** : doublon tranché en faveur de la branche déjà écrite. Anti-hallucination vérifié à la relecture — lecture échouée ou prévision absente ⇒ `None` et retour à la cascade, jamais de valeur fabriquée +- ✅ #T359 — vocal — 17/08 — **couvert par #T367**, voir ci-dessus +- ✅ #T360 — routage — 17/08 — PR #324 #334 — sonde d'accessibilité d'hôte (`core/llm/provider_health.py`) : ouverture TCP de 0,3 s avant le premier essai, cache 300 s en succès / 60 s en échec, une seule sonde en vol par hôte. Écarte le PC éteint que le disjoncteur de #T352 ne pouvait pas écarter (il n'ouvre qu'après 3 échecs). Appliquée aux **deux** chemins, fast-path et cascade streamée. Garde-fou relu : le **dernier** candidat n'est jamais sondé — on ne coupe pas la dernière option +- ✅ #T361 — sessions/dag — 17/08 — PR #343 — les tâches non terminales des sessions zombifiées sont réconciliées dans la **même transaction** que l'`UPDATE sessions`, sur les seules sessions zombifiées par cet appel (jamais un `UPDATE dag_tasks` global). Prédicat inchangé : `waiting_approval` jamais touché. ⚠️ **Le commit `e9f15f4` est étiqueté `#T362` par erreur** — il livre bien #T361 +- ✅ #T346 — archi — 17/08 — PR #336 — canal de raisonnement de bout en bout (provider → gateway → pipeline → SSE) **et** outils vocaux sur le chemin streamé. Événements `thinking` / `thinking_done` / `tool_call` / `tool_result` émis par `stream_discussion_fast_path_sse` et `core/vocal_tools.py:569,579`. ⚠️ **Portée réelle : la branche `mode=chat` seulement** — le pipeline complet n'est pas couvert, d'où #T381 +- ✅ #T369 — vocal — 17/08 — PR #335 — la mesure de latence vocale et les chemins d'écriture sont verrouillés par des tests +- ✅ #T373 #T380 — vocal — 17/08 — PR #345 — heure et date répondues en zéro-LLM ; la sonde est libérée par `bulk-status` +- ✅ #T379 — sessions — 17/08 — PR #344 — la session est close en fin d'exécution avec une cause nommée +- ✅ #T353-a — providers — 17/08 — PR #339 — AJEAN (llama.cpp, `Qwen2.5-14B-Instruct-1M-Q4_K_M`) déclaré **sans** être branché en cascade, par IP LAN et jamais par nom DNS pour rester dans la famille locale de `_est_hote_local` (#T337). Reste bloqué par l'action Axel : `--host 0.0.0.0` + pare-feu 8080 +- ✅ #T305-a — ihm-v2 — 17/08 — PR #337 — le tableau de tâches DAG est requalifié et spécifié (`docs/specs_ihm_v2/T305_TaskKanbanBoard_Spec.md`) : c'est une **création**, pas un câblage +- ✅ *(sans id)* — contrat — 17/08 — PR #338 — le contrat approuvé devient un plancher inaliénable, incident du 17/08 rejoué en test +- ✅ *(sans id)* — sonde/catalogue — 17/08 — PR #340 #341 — backoff et classement de cause sur les modèles éteints, alerte une seule fois ; règle explicite + audit de la zone grise du catalogue +- ✅ *(sans id)* — journal — 17/08 — PR #342 — hygiène du journal du Deck : warning TLS unique, `HTTPError` sans réponse rendu diagnosticable +- ✅ *(sans id)* — dag — 17/08 — PR #347 — watchdog configurable par tier au lieu d'un délai unique de 120 s + +## 17/08/2026 + +- ✅ #T351 — vocal — 17/08 — PR #319 — les demandes météo/agenda formulées sans le mot-clé attendu ne partent plus en LLM nu +- ✅ #T350 — domotique — 17/08 — PR #318 — les commandes de volet passent par `script.turn_on` et n'attendent plus les 26 s du script. Vérifié après merge : **le piège a été évité** — le payload porte bien `"variables": dict(service_data)` (sans quoi HA aurait répondu 200 avec un volet immobile), et `tests/unit/test_ha_script_turn_on_t350.py` (178 lignes) asserte le corps du POST +- ✅ #T349 — billing — 17/08 — PR #317 — solde des fournisseurs historisé dans `billing_history` depuis le collecteur de quotas, sans appel réseau ajouté +- ✅ #T348 — tests — 17/08 — PR #316 — la suite n'écrit plus dans la base réelle du dépôt (EventStore aligné sur `runtime_db`, isolation compatible CI) +- ✅ #T347 — coût — 17/08 — PR #315 — le cache-hit DeepSeek est compté à son tarif (0,003 $/M contre 0,14 $/M) au lieu du tarif plein + +- ✅ #T345 — agents — 17/08 — PR #311 — le PATCH distingue « champ absent » de « fourni à `null` » via `body.model_fields_set` (`api/routes/agents_crud.py:376`) : `null` explicite rend tous les outils, un PATCH partiel n'efface plus les permissions. Vérifié après merge : le test du champ ABSENT existe bien (c'était le garde-fou de moindre privilège), la config de test est isolée (`tmp_path` + monkeypatch), et le NB obsolète de `ihm-v2/src/api/agents.ts` est remplacé par la sémantique réelle +- ✅ #T344 — api — 17/08 — PR #312 — le portillon `begin_execution` est descendu juste avant `run_full_pipeline` ; les 5 chemins légers ne prennent plus de créneau. Vérifié après merge : la contre-pression du dreamer est **préservée** par un compteur de requêtes en vol distinct (`state.begin_request`/`end_request`, toujours libéré dans le `finally`), le doublon est vérifié tôt sans consommer de créneau ni payer `analyze_request`, et `except HTTPException: raise` empêche le 409 d'être avalé par le repli générique +- ✅ #T169 — gateway — 17/08 — PR #313 — verdict : les deux symptômes sont CADUC, aucun changement de comportement. (a) `zai-glm-4.7` est un modèle Cerebras par design, résolution par clé exacte ; hypothèse « clé absente → repli muet » **réfutée** : sans clé le provider n'est pas construit et l'accès lève une ValueError. (b) le read de 120 s est le défaut de la famille `openai_compat`, aligné sur la fenêtre interactive — arbitrage produit, pas un bug. Verdicts + table des timeouts figés dans `tests/unit/test_t169_gateway_verdicts.py` +- ✅ #T337 — routage — 17/08 — PR #309 — `MOTEUR_PRIVACY_LEVEL=local_only` **fail-closed** : aucun local disponible → échec explicite, jamais de repli cloud. Vérifié : le test central asserte `cloud_provider.calls == 0` ET le contenu de la liste de repli du `FallbackProvider`, pas seulement son premier élément ; famille locale déterminée par `_is_local_provider()`, pas par une liste de noms +- ✅ #T290 — ihm-v2 — 17/08 — PR #308 — `ToolPermissionSelector` câblé à la création ET à l'édition (`AgentsManager.tsx:137,330`), les trois états `null`/`[]`/liste transitent distinctement. **A révélé #T345** : le PATCH backend ignore `null`, donc on ne peut pas revenir à « tous les outils » +- ✅ #T289 — ihm-v2 — 17/08 — PR #307 — `CustomModelModal` monté dans `LLMRegistry.tsx:14,521` et repointé de `/api/models/custom` (inexistant) vers `POST /api/models/update` ; vérifié : aucun `.py` touché par le lot +- ✅ #T344-a — api — 17/08 — PR #304 — le mode Discussion ne prend plus de créneau du plafond de concurrence et ne paie plus un `analyze_request()` jeté ; reste #T344 pour les 5 autres chemins légers +- ✅ #T304 — ihm-v2 — 17/08 — **déjà livré, énoncé erroné** — le SSE mot-à-mot existe (`api/routes/streaming.py:66` et `services/pipeline_service.py:1125` qui émet `{'type':'token'}`). Le board cherchait `/api/stream/chat`, le chemin réel est `/api/chat/stream`. Ce qui manque vraiment (types d'événements `thinking`/`tool_call`) est requalifié en #T346 +- ✅ #T343 — agents — 17/08 — PR #301 #302 #303 — (a) un JSON d'arguments illisible n'exécute plus l'outil à vide, et le résumé du tour ne le compte plus comme un dépassement de borne ; (b) hystérésis de sortie consécutive + notification échouée rejouée, cycles concurrents sérialisés par `asyncio.Lock` (`core/quota_alert.py:54`) ; (c) le chemin streaming `/v1` porte l'étiquette `proxy_v1` jusque dans le thread du pool +- ✅ #T324 — api — 17/08 — PR #305 — verrou d'exécution par session + plafond `MOTEUR_EXECUTION_MAX_CONCURRENCY` (défaut 3), vue agrégée `execution_state` intacte ; suite ouverte en #T344 (PR #304) +- ✅ #T316 — llm — 16/08 — PR #300 — plafond de sortie par défaut sur les providers OpenAI-compatibles. Énoncé retourné à la mesure : les six complétions à 40 000 tokens étaient **réelles** (modèle parti en boucle), le comptage en base était juste +- ✅ #T303 — refactoring — 17/08 — **déjà livré**, board en retard — `core/dag/map_reduce.py` (435 lignes) existe et `core/dag_runner.py:50` déclare `DAGRunner(MapReduceMixin, SubgraphMixin)` ; vérifié le 17/08 +- ✅ #T307 — mémoire — 17/08 — **déjà livré**, board en retard — `core/router.py:569` importe et appelle `sync_db_to_markdown` via `asyncio.to_thread` sur le chemin « ENREGISTRE » (`core/router.py:541`) ; garde-fous dans `tests/unit/test_sync_db_to_markdown.py` ; vérifié le 17/08 + ## 13/08/2026 +- ✅ #T331 — providers — 13/08 — PR #298 + D-8 — OR=`openrouter/auto` ; Claude API hors cascade ; Dashscope off (401 + CGU) - ✅ #T342 — docs — 12/08 — PR #291 — kit `docs/travail/` + `prompts/agents/` dans le dépôt (`4681959`) - ✅ #T340 — outils — 12/08 — PR #290 — token HA configuré prime sur l'en-tête inventé par le modèle - ✅ #T325 — observabilité — 12/08 — PR #293 — `/api/metrics/cost-per-success` par session (prod 13/08 : 406 succès, $0.00027/succès) diff --git a/gui_server.py b/gui_server.py index 56523b6..f861b09 100644 --- a/gui_server.py +++ b/gui_server.py @@ -90,6 +90,23 @@ async def lifespan(app: FastAPI): """ global GLOBAL_ROUTER + # ── Journalisation de la version du code en cours d'exécution (#T355) ── + # Une ligne au démarrage : SHA court + date du commit. Si le commit est + # indéterminable (git absent/échoué), on le dit explicitement — jamais une + # valeur d'apparence valide. + try: + from core.version_info import get_version_info + _vinfo = get_version_info() + if _vinfo.get("commit"): + logger.info( + "[STARTUP] 🏷️ Version du code : %s (commit %s)", + _vinfo["commit"], _vinfo["commit_date"], + ) + else: + logger.warning("[STARTUP] 🏷️ Version du code indéterminable : %s", _vinfo.get("reason")) + except Exception as _ve: # pragma: no cover - défensif, ne doit jamais bloquer le boot + logger.warning("[STARTUP] 🏷️ Journalisation de la version impossible : %s", _ve) + # ── Initialisation du sérialiseur SQLite ── from core.async_db_serializer import AsyncDBSerializer db_serializer = AsyncDBSerializer.get_instance() @@ -254,7 +271,7 @@ async def lifespan(app: FastAPI): try: from core.watchdog import create_watchdog_daemon _watchdog = create_watchdog_daemon({ - "mqtt_host": os.getenv("MQTT_HOST", "192.168.1.x"), + "mqtt_host": os.getenv("MQTT_HOST", "192.168.1.10"), "mqtt_port": int(os.getenv("MQTT_PORT", "1883")), "mqtt_username": os.getenv("MQTT_USERNAME"), "mqtt_password": os.getenv("MQTT_PASSWORD"), @@ -328,7 +345,7 @@ async def lifespan(app: FastAPI): # Configuration CORS pilotée par l'environnement. # MOTEUR_CORS_ORIGINS : liste d'origines séparées par des virgules -# (ex. "http://192.168.1.x:8000,http://localhost:8000"). +# (ex. "http://192.168.1.10:8000,http://localhost:8000"). # Sécurité : la combinaison allow_origins=["*"] + allow_credentials=True est # invalide/dangereuse (CSRF cross-origin authentifié). Si aucune origine n'est # définie, on retombe sur "*" SANS credentials. diff --git a/ha_commands.json.example b/ha_commands.json.example index d726be8..13eeffb 100644 --- a/ha_commands.json.example +++ b/ha_commands.json.example @@ -1,5 +1,5 @@ { - "_comment": "Phrases exactes après normalisation router (minuscules, sans accents finaux). entity_id vérifiés contre HA Freebox live 2026-07-14.", + "_comment": "Phrases exactes après normalisation router (minuscules, sans accents finaux). Exemple anonymisé — remplacer les entity_id par les vôtres.", "commands": [ { "phrases": [ @@ -100,7 +100,9 @@ ], "service": "script.blind_action", "entity_id": "", - "service_data": {"action": "open"} + "service_data": { + "action": "open" + } }, { "phrases": [ @@ -117,7 +119,9 @@ ], "service": "script.blind_action", "entity_id": "", - "service_data": {"action": "close"} + "service_data": { + "action": "close" + } }, { "phrases": [ @@ -134,7 +138,9 @@ ], "service": "script.blind_action", "entity_id": "", - "service_data": {"action": "stop"} + "service_data": { + "action": "stop" + } }, { "phrases": [ diff --git a/ihm-v2/.gitignore b/ihm-v2/.gitignore new file mode 100644 index 0000000..4625286 --- /dev/null +++ b/ihm-v2/.gitignore @@ -0,0 +1,10 @@ +node_modules +dist +dist-ssr +*.tsbuildinfo +*.local +.vite +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.DS_Store diff --git a/ihm-v2/README.md b/ihm-v2/README.md index 8c42908..140fc93 100644 --- a/ihm-v2/README.md +++ b/ihm-v2/README.md @@ -48,7 +48,7 @@ cd ihm-v2 && npm install && npm run dev ``` Vite sur , proxy `/api`, `/v1`, `/ws`, `/version` → `:8000`. -Cible surchargeable : `MOTEUR_API_URL=http://${ENGINE_HOST:-192.168.1.x}:8000 npm run dev`. +Cible surchargeable : `MOTEUR_API_URL=http://192.168.1.10:8000 npm run dev`. Le moteur (`gui_server.py`) doit tourner et autoriser l'origine de dev dans `MOTEUR_CORS_ORIGINS` (ex : `http://localhost:5173`). diff --git a/ihm-v2/index.html b/ihm-v2/index.html new file mode 100644 index 0000000..c9639dc --- /dev/null +++ b/ihm-v2/index.html @@ -0,0 +1,12 @@ + + + + + + tab5-engine — IHM + + +
+ + + diff --git a/ihm-v2/package-lock.json b/ihm-v2/package-lock.json new file mode 100644 index 0000000..79ce017 --- /dev/null +++ b/ihm-v2/package-lock.json @@ -0,0 +1,3926 @@ +{ + "name": "ihm-moteur-v2", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ihm-moteur-v2", + "version": "0.1.0", + "dependencies": { + "@tanstack/react-query": "^5.101.4", + "@xyflow/react": "^12.11.2", + "chart.js": "^4.4.7", + "highlight.js": "^11.11.1", + "lucide-react": "^1.30.0", + "react": "^19.0.0", + "react-chartjs-2": "^5.3.0", + "react-dom": "^19.2.8", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.2.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@oxc-project/types": { + "version": "0.142.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", + "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.2.tgz", + "integrity": "sha512-l7x215OGvo1s52JWmR8U/DAVzEDWBCIbTm28aeJV/WDTSHgcKXaZTuBT0hJMs5NggilfJTW3clZVvd24yfKJxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.2.tgz", + "integrity": "sha512-9u9Xv6c1AJZT0FfwH5vrMG5Jjcwhc1MlyrPu0XfTqkzsmqfks2M6W/o5XwAJgVVN/jHpqqngC1WevHKKTIUtIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.2.tgz", + "integrity": "sha512-9W1mbGZAfW3oqd85bhBkmpyHCCzL1TeG/zFFP3vg7b0rlly8cxOcre5nXwz+LHazCwac2MNWgPdPCHndABjpWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.2.tgz", + "integrity": "sha512-0p1lhiCSCyaerFwtrdZQUx7NqGk6LQnaRKWX7tFQqwQgvX0rjM15cIkm3pax1UpEakK14C4mOxx/jSqCBdBRqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.2.tgz", + "integrity": "sha512-e+cOJXrJ2L3zx6YzqPg+f6Wbk3V1cKB8bOhbaYdVYN3DdquzNdRAmrbETz1qnt5yp/c7JNlNjmITiA2cVneQ7w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.2.tgz", + "integrity": "sha512-JsSMsj6sNat/MuhG5fnBD7QgbtpHKVe30x5/bAVirDHdhoQRXJkF6xc0Jqk8O4fiCUQAzMOoH9wZi3m60c8wtg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.2.tgz", + "integrity": "sha512-B5G/zJdHaoJn9vD50eGHWkiWfmq8Uhi3IiLPJTzmZTrAalk1bztUikSXo0qga18ibE0IXboyeMUnhPjhAJ45wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.2.tgz", + "integrity": "sha512-6mC/awzKka8W6EoekjegpfGkjz8jXWDX63pqu/HYVpyKtZfu65Jsh4QAH3Kej3CAv/c1oGX7psTmFEbr0mDxLA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.2.tgz", + "integrity": "sha512-412MX9fJLdA1IK28EZnc8jYv2HRTleOZgfLQumJ5zy7OeJLZlg/CETwFaXjNmGVxG51cFHpKLqb5LKvBC+HsHA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.2.tgz", + "integrity": "sha512-Q/+HI/ToJafZ1iCqGgVQXUEkIjufHCTF0gBQ2a5o3cg7GJ2h0qyq3nvvSmU+bGda2/7ygXpTY4TM6gO9OhQ0ZA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.2.tgz", + "integrity": "sha512-ZKp/w41n6wCvxzxQHtQSbuphfX3Y4cCvbjkKHusrLx4lh+JWLTU7StSltO/DKARISzbj368d+qUaCjI8K2wzXw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.2.tgz", + "integrity": "sha512-pxE6xD4KS3eAROkKK5yrhB9/3+vhlhVGMvlQLbdpzrBGDbKrnzx3RLwPaHvasLo6jgaiBLn7e04Df9C4tYhjmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.2.tgz", + "integrity": "sha512-4MqEue5re+xIZzAWsB8sj0P1kqZySWqIuN4t6QaIO/YA6SFwySOLruvWQFzfmqk8LBK2P30KCSJwf6mJCZZ5/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.2.tgz", + "integrity": "sha512-NweNxxD0Nf9t8v7kodun45Ijp3EIwYY+uydPP6qBEYvfBqhIjN6dZMzlQja3tqX/aLs3F3Uz+AxDpKgRhpOZQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.18", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", + "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@xyflow/react": { + "version": "12.11.2", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.11.2.tgz", + "integrity": "sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.79", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": ">=17", + "react-dom": ">=17" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.79", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.79.tgz", + "integrity": "sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.4", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.4.tgz", + "integrity": "sha512-GVoi+ICHocoOIU7qVVM48wOJziRsqrsyqlI0Ce0LdowRn6v3bcH2zUa9kp85ncx0nwIb9/HOCOLS3fdThDG/XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lucide-react": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.30.0.tgz", + "integrity": "sha512-tUIr2jXLbWpCkdtH8XP7P7YppM9ueWgTky99lpWDY6z5REs6B+O6ZQ3U5tHkUUY59ANyOv/PBcs8E4Fe3KO3eA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rolldown": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.2.tgz", + "integrity": "sha512-opwpo1tQBAcpSUJDt94B7hhLNGOKjCdE//XXjeLrnx9b83bjnw45tXdg1b09yEw/VLFBJGZpwRULMmOZo7ol+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.142.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.2", + "@rolldown/binding-darwin-arm64": "1.2.2", + "@rolldown/binding-darwin-x64": "1.2.2", + "@rolldown/binding-freebsd-x64": "1.2.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.2", + "@rolldown/binding-linux-arm64-gnu": "1.2.2", + "@rolldown/binding-linux-arm64-musl": "1.2.2", + "@rolldown/binding-linux-ppc64-gnu": "1.2.2", + "@rolldown/binding-linux-s390x-gnu": "1.2.2", + "@rolldown/binding-linux-x64-gnu": "1.2.2", + "@rolldown/binding-linux-x64-musl": "1.2.2", + "@rolldown/binding-openharmony-arm64": "1.2.2", + "@rolldown/binding-win32-arm64-msvc": "1.2.2", + "@rolldown/binding-win32-x64-msvc": "1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/vite/node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/vite/node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/ihm-v2/package.json b/ihm-v2/package.json new file mode 100644 index 0000000..2972c04 --- /dev/null +++ b/ihm-v2/package.json @@ -0,0 +1,41 @@ +{ + "name": "ihm-moteur-v2", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "IHM v2 du Moteur Multi-Agents (refonte React/Vite/TS — spec docs/audits/spec_refonte_hmi.md)", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "build:v2": "tsc -b && vite build --base=/v2/", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "@tanstack/react-query": "^5.101.4", + "@xyflow/react": "^12.11.2", + "chart.js": "^4.4.7", + "highlight.js": "^11.11.1", + "lucide-react": "^1.30.0", + "react": "^19.0.0", + "react-chartjs-2": "^5.3.0", + "react-dom": "^19.2.8", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1", + "zustand": "^5.0.2" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.2.1" + } +} diff --git a/ihm-v2/src/api/agents.ts b/ihm-v2/src/api/agents.ts new file mode 100644 index 0000000..f1f10dd --- /dev/null +++ b/ihm-v2/src/api/agents.ts @@ -0,0 +1,82 @@ +import { apiFetch } from "./client"; + +/** + * Contrat réel du CRUD /api/agents (#T159, api/routes/agents_crud.py). + * - core : agents du pipeline ; "model" = clé `_model` de config.json + * (tier leger/moyen/fort/automatique OU id de modèle littéral). + * - persistent : daemon/dreamer ; enabled/interval = persistent_agents.* de config.json. + * - custom : section custom_agents de config.json (réellement chargée par le + * moteur) ou nœuds de l'éditeur de workflow (source "workflow", + * gérés là-bas). + * Pas de temperature/max_tokens ici : aucun réglage réel de ce type n'existe + * côté moteur — on n'expose pas de faux boutons. + */ +export interface AgentConfig { + name: string; + label: string; + kind: "core" | "persistent" | "custom"; + enabled: boolean; + can_disable: boolean; + model: string | null; + tier: string; + system_prompt: string | null; + prompt_editable: boolean; + prompt_source: "markdown" | "defaut_code" | "config" | null; + is_custom: boolean; + interval_minutes?: number | null; + source: "config" | "workflow"; + // [#T233/#T290] Permissions d'outils. Trois états distincts : + // null = tous les outils, [] = aucun, liste non vide = sélection partielle. + // Rendu par le GET pour les agents custom (agents_crud.py:213). + allowed_tools?: string[] | null; +} + +export async function fetchAgents(): Promise { + const res = await apiFetch<{ agents: AgentConfig[] }>("/api/agents"); + return res.agents; +} + +export async function toggleAgent(name: string): Promise<{ enabled: boolean }> { + return apiFetch<{ enabled: boolean }>(`/api/agents/${encodeURIComponent(name)}/toggle`, { + method: "POST", + }); +} + +export interface AgentPatch { + model?: string; + tier?: string; + label?: string; + enabled?: boolean; + interval_minutes?: number; + system_prompt?: string; + // [#T233/#T290/#T345] Permissions d'outils (null = tous, [] = aucun, liste = partiel). + // Le PUT backend distingue « champ absent » (inchangé) de « fourni à null » + // (= tous les outils) via model_fields_set : envoyer explicitement null + // lève bien une restriction existante (#T345). + allowed_tools?: string[] | null; +} + +export async function updateAgent(name: string, patch: AgentPatch): Promise { + return apiFetch(`/api/agents/${encodeURIComponent(name)}`, { + method: "PUT", + body: JSON.stringify(patch), + }); +} + +export async function createAgent(config: { + name: string; + label?: string; + tier: string; + system_prompt?: string; + // [#T233/#T290] Permissions d'outils (null = tous, [] = aucun, liste = partiel). + allowed_tools?: string[] | null; +}): Promise { + return apiFetch("/api/agents", { + method: "POST", + body: JSON.stringify(config), + }); +} + +export async function deleteAgent(name: string): Promise { + return apiFetch(`/api/agents/${encodeURIComponent(name)}`, { method: "DELETE" }); +} diff --git a/ihm-v2/src/api/chat.ts b/ihm-v2/src/api/chat.ts new file mode 100644 index 0000000..cdd6151 --- /dev/null +++ b/ihm-v2/src/api/chat.ts @@ -0,0 +1,159 @@ +/** Appel SSE vers /api/execute/stream. */ +import { getApiKey, notifyAuthError } from "./client"; +import type { AgentStep, ToolCallData, ToolCallResult } from "../state/chatStore"; + +export interface StreamCallbacks { + onStep: (step: AgentStep) => void; + onDone: (response: string, agentsUsed: string[]) => void; + onError: (message: string) => void; + /** [#T291] Fragment de raisonnement (`thinking`) — jamais mêlé au contenu. */ + onThinking?: (text: string) => void; + /** [#T291] Fin du raisonnement (`thinking_done`), durée annoncée par le backend. */ + onThinkingDone?: (durationMs?: number) => void; + /** [#T291] Annonce d'un appel d'outil (`tool_call`), statut « running ». */ + onToolCall?: (call: ToolCallData) => void; + /** [#T291] Résultat d'un appel d'outil (`tool_result`). */ + onToolResult?: (result: ToolCallResult) => void; +} + +export interface WorkloadOptions { + /** [#T194] Force du workload : leger/moyen/fort/automatique (backend get_provider_for_tier). */ + tier?: "leger" | "moyen" | "fort" | "automatique"; + /** Override de modèle explicite (id littéral du catalogue), prioritaire sur tier. */ + model?: string; +} + +export async function executeStream( + userPrompt: string, + source: Record, + callbacks: StreamCallbacks, + signal?: AbortSignal, + workload?: WorkloadOptions, +): Promise { + const key = getApiKey(); + const res = await fetch("/api/execute/stream", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(key ? { Authorization: `Bearer ${key}` } : {}), + }, + body: JSON.stringify({ + user_prompt: userPrompt, + source, + ...(workload?.tier ? { tier: workload.tier } : {}), + ...(workload?.model ? { model: workload.model } : {}), + }), + signal, + }); + + if (!res.ok) { + notifyAuthError(res.status); + throw new Error(`HTTP ${res.status}`); + } + + const reader = res.body?.getReader(); + if (!reader) throw new Error("Pas de body dans la réponse SSE"); + + const decoder = new TextDecoder(); + let buf = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + + const lines = buf.split("\n"); + buf = lines.pop() ?? ""; + + for (const line of lines) { + if (!line.startsWith("data:")) continue; + const raw = line.slice(5).trim(); + if (!raw) continue; + try { + const evt = JSON.parse(raw) as { + type: string; + data?: Record; + response?: string; + agents_used?: string[]; + message?: string; + text?: string; + durationMs?: number; + id?: string; + toolName?: string; + args?: Record; + status?: string; + resultData?: unknown; + errorMessage?: string; + executionTimeMs?: number; + }; + + switch (evt.type) { + // ── Étapes du pipeline complet (source.mode = "default") ── + // Le backend émet `agent_started` / `agent_completed` / `phase_changed` + // (`core/engine.py:370,394,1127`), jamais `orchestration_step` : ce nom + // n'existe nulle part côté Python (vérifié par grep sur tout le dépôt). + // L'ancien code n'écoutait que ce nom fantôme — les badges d'étapes de + // la vue Chat et du Prompt Studio ne se sont donc jamais affichés. + case "agent_started": + case "agent_completed": + callbacks.onStep({ + agent: (evt.data?.agent_name as string) ?? (evt.data?.agent as string) ?? "agent", + status: + (evt.data?.status as string) ?? + (evt.type === "agent_started" ? "en cours" : "terminé"), + }); + break; + + case "phase_changed": + callbacks.onStep({ + agent: "pipeline", + status: (evt.data?.phase as string) ?? "", + }); + break; + + // ── Raisonnement et outils (source.mode = "chat", #T346) ── + case "thinking": + callbacks.onThinking?.(evt.text ?? ""); + break; + + case "thinking_done": + callbacks.onThinkingDone?.(evt.durationMs); + break; + + case "tool_call": + callbacks.onToolCall?.({ + id: evt.id ?? "", + toolName: evt.toolName ?? "outil", + args: evt.args ?? {}, + status: "running", + }); + break; + + case "tool_result": + callbacks.onToolResult?.({ + id: evt.id ?? "", + toolName: evt.toolName ?? "outil", + status: evt.status === "error" ? "error" : "success", + resultData: evt.resultData, + errorMessage: evt.errorMessage ?? undefined, + executionTimeMs: evt.executionTimeMs, + }); + break; + + case "done": + callbacks.onDone(evt.response ?? "", evt.agents_used ?? []); + return; + + case "error": + callbacks.onError(evt.message ?? "Erreur inconnue"); + return; + + // heartbeat / token / sentence / aborted → silencieux (la vue Chat + // n'affiche pas le contenu mot-à-mot : elle rend la réponse sur `done`). + } + } catch { + /* ligne SSE non-JSON (ping, commentaire) */ + } + } + } +} diff --git a/ihm-v2/src/api/client.ts b/ihm-v2/src/api/client.ts new file mode 100644 index 0000000..b5b23fe --- /dev/null +++ b/ihm-v2/src/api/client.ts @@ -0,0 +1,105 @@ +/** + * Client API du moteur — authentification propre (HMI v2). + * + * Modèle d'auth vérifié contre core/auth.py + gui_server.py : + * - REST /api/* et /v1/* : header `Authorization: Bearer `. + * - SSE/WS : ticket éphémère à USAGE UNIQUE via POST /api/auth/ticket, + * puis `?ticket=` (la vraie clé ne transite jamais + * dans l'URL). C'est ce qui permettra de retirer le fallback + * déprécié `?token=` (#T65, core/auth.py:204). + * + * La clé est stockée en localStorage (outil personnel sur LAN — cf. spec §7). + */ + +const KEY_STORAGE = "moteur_api_key"; + +export function getApiKey(): string { + return localStorage.getItem(KEY_STORAGE) ?? ""; +} + +export function setApiKey(key: string): void { + if (key) localStorage.setItem(KEY_STORAGE, key.trim()); +} + +export function clearApiKey(): void { + localStorage.removeItem(KEY_STORAGE); +} + +export class ApiError extends Error { + constructor( + public status: number, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +/** Indique une erreur d'authentification (clé manquante/invalide ou non configurée serveur). */ +export function isAuthError(err: unknown): boolean { + return err instanceof ApiError && [401, 403].includes(err.status); +} + +/** + * Callback global notifié sur tout 401/403 renvoyé par le moteur, quelle que soit + * la vue à l'origine de l'appel. Enregistré une fois par App.tsx (réaffiche + * ApiKeyGate) — avant ce correctif, seul le flux SSE (useEngineStream) le faisait, + * laissant les ~15 autres vues REST afficher une erreur muette sans recours. + */ +let authErrorHandler: (() => void) | null = null; + +export function registerAuthErrorHandler(handler: (() => void) | null): void { + authErrorHandler = handler; +} + +/** À appeler par tout code qui fait sa propre requête HTTP (hors apiFetch) sur un statut non-2xx. */ +export function notifyAuthError(status: number): void { + if (status === 401 || status === 403) authErrorHandler?.(); +} + +/** Appel REST authentifié (Bearer). Lève ApiError sur statut non-2xx. */ +export async function apiFetch( + path: string, + init: RequestInit = {}, +): Promise { + const key = getApiKey(); + const headers = new Headers(init.headers); + if (key && !headers.has("Authorization")) { + headers.set("Authorization", `Bearer ${key}`); + } + if (init.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const res = await fetch(path, { ...init, headers }); + + if (!res.ok) { + let detail = res.statusText; + try { + const body = await res.json(); + detail = (body as { detail?: string })?.detail ?? detail; + } catch { + /* corps non-JSON : on garde statusText */ + } + notifyAuthError(res.status); + throw new ApiError(res.status, detail); + } + + if (res.status === 204) return undefined as T; + const ct = res.headers.get("Content-Type") ?? ""; + return (ct.includes("application/json") ? await res.json() : await res.text()) as T; +} + +/** + * Demande un ticket éphémère (usage unique, ~60s) pour ouvrir un flux SSE/WS. + * Retourne l'URL du flux avec `?ticket=` prêt à l'emploi pour EventSource. + */ +export async function buildStreamUrl(streamPath: string): Promise { + const { ticket } = await apiFetch<{ ticket: string; expires_in: number }>( + "/api/auth/ticket", + { method: "POST" }, + ); + const url = new URL(streamPath, window.location.origin); + url.searchParams.set("ticket", ticket); + return url.toString(); +} diff --git a/ihm-v2/src/api/config.ts b/ihm-v2/src/api/config.ts new file mode 100644 index 0000000..a858889 --- /dev/null +++ b/ihm-v2/src/api/config.ts @@ -0,0 +1,31 @@ +/** Endpoints de configuration (config.json éditable + présence des clés .env). */ +import { apiFetch } from "./client"; + +export type EngineConfig = Record; + +/** GET /api/config → contenu de config.json. */ +export function fetchConfig(): Promise { + return apiFetch("/api/config"); +} + +/** + * POST /api/config → merge partiel. On n'envoie que les clés de premier niveau + * modifiées (le backend fait `config.update(body)`). + */ +export function updateConfig(patch: EngineConfig): Promise<{ + message: string; + config: EngineConfig; +}> { + return apiFetch("/api/config", { + method: "POST", + body: JSON.stringify(patch), + }); +} + +/** GET /api/keys → présence (booléenne) des secrets .env, sans les valeurs. */ +export function fetchKeys(): Promise<{ + keys: Record; + configured_count: number; +}> { + return apiFetch("/api/keys"); +} diff --git a/ihm-v2/src/api/engine.ts b/ihm-v2/src/api/engine.ts new file mode 100644 index 0000000..ce86207 --- /dev/null +++ b/ihm-v2/src/api/engine.ts @@ -0,0 +1,25 @@ +/** Endpoints moteur utilisés par le Dashboard. */ +import { apiFetch } from "./client"; +import type { ExecutionState } from "../types/engine"; + +/** État d'exécution courant (GET /api/status). */ +export function fetchStatus(): Promise { + return apiFetch("/api/status"); +} + +/** Lance une tâche en arrière-plan (POST /api/run). Les événements arrivent via /api/stream. */ +export function runTask(objective: string): Promise<{ + message: string; + status: string; + session_id: string; +}> { + return apiFetch("/api/run", { + method: "POST", + body: JSON.stringify({ objective }), + }); +} + +/** Demande l'arrêt de l'exécution en cours (POST /api/stop). */ +export function stopExecution(): Promise<{ message: string; status: string }> { + return apiFetch("/api/stop", { method: "POST" }); +} diff --git a/ihm-v2/src/api/models.ts b/ihm-v2/src/api/models.ts new file mode 100644 index 0000000..0fc63e6 --- /dev/null +++ b/ihm-v2/src/api/models.ts @@ -0,0 +1,168 @@ +import { apiFetch } from "./client"; + +/** + * Contrat réel de GET /api/models/registry (#T158, api/routes/context.py) : + * TOUS les modèles (actifs + inactifs), joints à leurs métriques réelles. + * Les champs sans donnée sont null — jamais de valeur inventée côté vue. + */ +export interface ModelInfo { + id: string; + name: string; + provider: string; + /** Canal d'accès (free/paid/subscription/local…), colonne `tier` de la DB. */ + tier: string; + /** Capacité de routage (leger/moyen/fort) ou null si non câblé (#T186). */ + routing_tier: "leger" | "moyen" | "fort" | null; + enabled: boolean; + /** Elo moyen inter-domaines (model_elo_scores) — null si jamais évalué. */ + elo_score: number | null; + /** Latence moyenne réelle (Circuit Breaker, repli Elo puis ttft_ms). */ + avg_latency_ms: number | null; + cost_usd_30d: number | null; + calls_30d: number | null; + /** Appels depuis TOUJOURS (#T243) — 30 jours ne suffit pas à décider d'une désactivation. */ + calls_total: number; + cost_per_success: number | null; + circuit_breaker_status: "CLOSED" | "OPEN" | "HALF_OPEN" | null; + /** false = au catalogue mais pas câblé dans le gateway (#T186). */ + is_wired: boolean; + /** + * Le modèle figure-t-il encore dans le listing de son API ? (#T243) + * + * `null` = on ne sait pas (aucun inventaire généré, ou provider muet au dernier + * passage) — à ne JAMAIS confondre avec `false`. Et `false` ne veut pas dire + * « mort » : un alias encore servi peut être absent du listing (`deepseek-chat` + * répond alors qu'il n'y figure pas). C'est un indice, pas une preuve. + */ + offered_by_api: boolean | null; + cost_input_per_m: number | null; + cost_output_per_m: number | null; +} + +/** Bilan du dernier inventaire live des API (#T243). */ +export interface InventaireResume { + genere_le: string | null; + providers_ok: number; + providers_total: number; + modeles_recenses: number; + par_provider?: Record; +} + +/** + * Candidat à la désactivation (#T243) : jamais appelé de toute l'histoire du moteur + * ET injoignable faute d'être câblé dans le gateway. + * + * Les deux conditions sont exigées à dessein. « Jamais appelé » seul ne suffit pas : + * un modèle câblé mais inutilisé peut être un repli de cascade légitime, qui ne sert + * précisément que le jour où les autres tombent. + */ +export function estCandidatDesactivation(m: ModelInfo): boolean { + return m.enabled && m.calls_total === 0 && !m.is_wired; +} + +interface RegistryModel { + id: string; + provider_id?: string; + display_name?: string; + status?: string; + tier?: string; + routing_tier?: "leger" | "moyen" | "fort" | null; + elo_score?: number | null; + avg_latency_ms?: number | null; + cost_usd_30d?: number | null; + calls_30d?: number | null; + calls_total?: number | null; + cost_per_success?: number | null; + circuit_breaker_status?: "CLOSED" | "OPEN" | "HALF_OPEN" | null; + is_wired?: boolean; + offered_by_api?: boolean | null; + cost_input_per_m?: number | null; + cost_output_per_m?: number | null; +} + +export async function fetchModels(): Promise { + const res = await apiFetch<{ models: RegistryModel[] }>("/api/models/registry"); + return (res.models ?? []).map((m) => ({ + id: m.id, + name: m.display_name ?? m.id, + provider: m.provider_id ?? "inconnu", + tier: m.tier ?? "?", + routing_tier: m.routing_tier ?? null, + enabled: m.status === "active", + elo_score: m.elo_score ?? null, + avg_latency_ms: m.avg_latency_ms ?? null, + cost_usd_30d: m.cost_usd_30d ?? null, + calls_30d: m.calls_30d ?? null, + calls_total: m.calls_total ?? 0, + cost_per_success: m.cost_per_success ?? null, + circuit_breaker_status: m.circuit_breaker_status ?? null, + is_wired: m.is_wired ?? false, + // `?? null` et non `?? false` : l'absence d'information n'est pas une absence + // du listing. Un backend antérieur à #T243 ne renvoie pas ce champ. + offered_by_api: m.offered_by_api ?? null, + cost_input_per_m: m.cost_input_per_m ?? null, + cost_output_per_m: m.cost_output_per_m ?? null, + })); +} + +/** Bilan du dernier inventaire (lecture seule, aucun appel réseau vers les API). */ +export async function fetchInventaire(): Promise { + const res = await apiFetch<{ inventaire: InventaireResume }>("/api/models/inventory"); + return res.inventaire; +} + +/** Relance l'interrogation de toutes les API de listing. Plusieurs secondes. */ +export async function refreshInventaire(): Promise { + const res = await apiFetch<{ inventaire: InventaireResume }>( + "/api/models/inventory/refresh", + { method: "POST" }, + ); + return res.inventaire; +} + +export async function toggleModel(id: string): Promise<{ enabled: boolean }> { + return apiFetch<{ enabled: boolean }>(`/api/models/${encodeURIComponent(id)}/toggle`, { + method: "POST", + }); +} + +export async function pingModel( + id: string, +): Promise<{ ok: boolean; latency_ms: number; error?: string }> { + return apiFetch<{ ok: boolean; latency_ms: number; error?: string }>( + `/api/models/${encodeURIComponent(id)}/ping`, + { method: "POST" }, + ); +} + +/** Résultat de la bascule groupée (#T243) — les ids inconnus n'échouent pas le lot. */ +export interface BulkStatusResult { + status: "ok" | "partiel"; + applique: "active" | "inactive"; + modifies: string[]; + inchanges: string[]; + introuvables: string[]; + echecs: string[]; +} + +/** Active ou désactive plusieurs modèles en un appel (#T243). */ +export async function setModelsStatus( + ids: string[], + status: "active" | "inactive", +): Promise { + return apiFetch("/api/models/bulk-status", { + method: "POST", + body: JSON.stringify({ ids, status }), + }); +} + +/** Change le routing_tier (capacité leger/moyen/fort, null = hors routage). */ +export async function setRoutingTier( + id: string, + routing_tier: "leger" | "moyen" | "fort" | null, +): Promise { + await apiFetch(`/api/models/${encodeURIComponent(id)}/routing-tier`, { + method: "POST", + body: JSON.stringify({ routing_tier }), + }); +} diff --git a/ihm-v2/src/api/observability.ts b/ihm-v2/src/api/observability.ts new file mode 100644 index 0000000..6f3a4fd --- /dev/null +++ b/ihm-v2/src/api/observability.ts @@ -0,0 +1,7 @@ +/** Endpoint de télémétrie pour la vue Observabilité. */ +import { apiFetch } from "./client"; +import type { Telemetry, TelemetryPeriod } from "../types/observability"; + +export function fetchTelemetry(period: TelemetryPeriod): Promise { + return apiFetch(`/api/metrics/telemetry?period=${encodeURIComponent(period)}`); +} diff --git a/ihm-v2/src/api/operations.ts b/ihm-v2/src/api/operations.ts new file mode 100644 index 0000000..0fd0b36 --- /dev/null +++ b/ihm-v2/src/api/operations.ts @@ -0,0 +1,96 @@ +import { apiFetch } from "./client"; + +/** + * Clients des endpoints d'exploitation réels du moteur (vue Opérations) : + * - GET /api/status : état d'exécution (AppState) + * - GET /api/observability/circuit-breakers : santé des CB (registre mémoire) + * - GET /api/backlog/quota : quotas/budget (BudgetGuard) + * - GET /api/daemon/status, /api/dreamer/status : agents persistants + * - GET /api/apis-status : vivacité DeepSeek/Gemini/Claude/Antigravity + * - GET /version : version du moteur (endpoint public) + */ + +export interface ExecutionStatus { + status?: string; + objective?: string; + session_id?: string; + error_message?: string | null; +} + +export async function fetchExecutionStatus(): Promise { + return apiFetch("/api/status"); +} + +export interface CircuitBreakerInfo { + name: string; + state: "CLOSED" | "OPEN" | "HALF_OPEN"; + failure_count: number; + total_calls: number; + total_failures: number; + total_trips: number; + rate_limits: number; + avg_latency_ms: number | null; +} + +export interface CircuitBreakersStatus { + uptime_seconds: number; + total: number; + open: number; + half_open: number; + healthy: number; + circuit_breakers: CircuitBreakerInfo[]; +} + +export async function fetchCircuitBreakers(): Promise { + return apiFetch("/api/observability/circuit-breakers"); +} + +export interface QuotaProvider { + available: boolean; + metric: string; + used: number; + limit: number; + unit: string; +} + +export interface QuotaSummary { + timestamp: number; + providers: Record; +} + +export async function fetchQuotas(): Promise { + return apiFetch("/api/backlog/quota"); +} + +/** Statuts daemon/dreamer : dicts libres côté backend, affichés champ par champ. */ +export type PersistentStatus = Record; + +export async function fetchDaemonStatus(): Promise { + return apiFetch("/api/daemon/status"); +} + +export async function fetchDreamerStatus(): Promise { + return apiFetch("/api/dreamer/status"); +} + +export interface ApisStatus { + deepseek?: { configured: boolean; active: boolean; real_balance_usd?: number; error?: string | null }; + gemini?: { configured: boolean; active: boolean; error?: string | null }; + claude?: { real_usage_pct?: number | null; summary_text?: string | null }; + antigravity?: { connected?: boolean; plan?: string; credits?: number }; +} + +export async function fetchApisStatus(): Promise { + return apiFetch("/api/apis-status"); +} + +export interface VersionInfo { + version: string; + git_hash?: string; + build_date?: string; +} + +export async function fetchVersion(): Promise { + // Endpoint public GET /version (gui_server.py) — PAS /api/version (n'existe pas). + return apiFetch("/version"); +} diff --git a/ihm-v2/src/api/prompt.ts b/ihm-v2/src/api/prompt.ts new file mode 100644 index 0000000..cb09fe1 --- /dev/null +++ b/ihm-v2/src/api/prompt.ts @@ -0,0 +1,13 @@ +import { apiFetch } from "./client"; + +/** POST /api/prompt/engineer (#T192) — PromptEngineerAgent branché sur l'IHM. */ +export async function engineerPrompt( + prompt: string, + context = "", + tier?: string, +): Promise<{ optimized_prompt: string; provider_used: string }> { + return apiFetch<{ optimized_prompt: string; provider_used: string }>("/api/prompt/engineer", { + method: "POST", + body: JSON.stringify({ prompt, context, ...(tier ? { tier } : {}) }), + }); +} diff --git a/ihm-v2/src/api/sessions.ts b/ihm-v2/src/api/sessions.ts new file mode 100644 index 0000000..df7f4b2 --- /dev/null +++ b/ihm-v2/src/api/sessions.ts @@ -0,0 +1,61 @@ +/** Endpoints historique des sessions (/api/sessions). */ +import { apiFetch } from "./client"; + +export interface Session { + session_id: string; + objective: string; + status: "success" | "error" | "running" | string; + started_at: number; // epoch float (seconds) + ended_at: number | null; + duration_ms: number | null; + starting_agent: string | null; + agents_invoked: string | null; // JSON array string + result_summary: string | null; +} + +export function fetchSessions(limit = 40): Promise<{ sessions: Session[] }> { + return apiFetch<{ sessions: Session[] }>(`/api/sessions?limit=${limit}`); +} + +// ─── Chat message persistence (T83) ─────────────────────────────────────────── + +export interface ChatMessagePersisted { + id: number; + chat_session_id: string; + role: "user" | "assistant"; + content: string; + agents_used: string[] | null; + created_at: number; +} + +export interface ChatSessionPersisted { + chat_session_id: string; + title: string | null; + created_at: number; + updated_at: number; + message_count: number; +} + +export interface SaveMessagesPayload { + messages: { role: string; content: string; agents_used?: string[]; created_at?: number }[]; + title?: string; +} + +export function fetchChatMessages(chatSessionId: string, limit = 200): Promise { + return apiFetch(`/api/chat/sessions/${chatSessionId}/messages?limit=${limit}`); +} + +export function saveChatMessages(chatSessionId: string, payload: SaveMessagesPayload): Promise<{ saved: number }> { + return apiFetch<{ saved: number }>(`/api/chat/sessions/${chatSessionId}/messages`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export function fetchChatSessions(limit = 50): Promise { + return apiFetch(`/api/chat/sessions?limit=${limit}`); +} + +export function deleteChatSession(chatSessionId: string): Promise<{ deleted: string }> { + return apiFetch<{ deleted: string }>(`/api/chat/sessions/${chatSessionId}`, { method: "DELETE" }); +} diff --git a/ihm-v2/src/components/charts/registerCharts.ts b/ihm-v2/src/components/charts/registerCharts.ts new file mode 100644 index 0000000..8e14e48 --- /dev/null +++ b/ihm-v2/src/components/charts/registerCharts.ts @@ -0,0 +1,31 @@ +/** Enregistrement unique des composants Chart.js utilisés par l'IHM. */ +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Tooltip, + Legend, + Filler, +} from "chart.js"; + +let registered = false; + +export function ensureChartsRegistered(): void { + if (registered) return; + ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Tooltip, + Legend, + Filler, + ); + ChartJS.defaults.color = "#94a3b8"; + ChartJS.defaults.borderColor = "rgba(148, 163, 184, 0.12)"; + registered = true; +} diff --git a/ihm-v2/src/components/common/ApiKeyGate.tsx b/ihm-v2/src/components/common/ApiKeyGate.tsx new file mode 100644 index 0000000..636686b --- /dev/null +++ b/ihm-v2/src/components/common/ApiKeyGate.tsx @@ -0,0 +1,57 @@ +/** + * Porte d'authentification : tant qu'aucune tab5-engine_API_KEY n'est saisie (ou si le + * serveur la refuse), on affiche un formulaire au lieu du dashboard. La clé n'est + * utilisée que pour le header Bearer / la demande de ticket — jamais dans une URL. + */ +import { useState } from "react"; +import { getApiKey, setApiKey } from "../../api/client"; + +interface Props { + needsKey: boolean; + onSubmit: () => void; +} + +export function ApiKeyGate({ needsKey, onSubmit }: Props) { + const [value, setValue] = useState(getApiKey()); + + if (!needsKey) return null; + + return ( +
+
{ + e.preventDefault(); + setApiKey(value); + onSubmit(); + }} + > +

🔐 Authentification du moteur

+

+ {/* Le nom affiché DOIT être celui que le backend lit réellement : `require_auth` + et `api/routes/setup.py` lisent `MOTEUR_API_KEY`. L'écran annonçait + `tab5-engine_API_KEY`, une variable qui n'existe nulle part côté moteur — + premier écran vu par un nouvel utilisateur, il l'envoyait chercher un + réglage inexistant. */} + Saisis la MOTEUR_API_KEY (header Bearer). Elle reste + locale à ce navigateur et n'apparaît jamais dans une URL. +

+ setValue(e.target.value)} + placeholder="MOTEUR_API_KEY" + className="w-full rounded-lg border border-slate-600 bg-slate-800 px-3 py-2 text-slate-100 outline-none focus:border-sky-500" + /> + +
+
+ ); +} diff --git a/ihm-v2/src/components/common/ErrorBoundary.tsx b/ihm-v2/src/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..6beb8b4 --- /dev/null +++ b/ihm-v2/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,44 @@ +import { Component, type ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +interface State { + error: Error | null; +} + +/** + * Filet de sécurité : sans ça, une exception de rendu dans une seule vue (ex: champ + * API manquant) démonte tout l'arbre React et laisse l'app blanche/inutilisable, + * y compris pour naviguer vers un autre onglet qui fonctionne. + */ +export class ErrorBoundary extends Component { + state: State = { error: null }; + + static getDerivedStateFromError(error: Error): State { + return { error }; + } + + componentDidCatch(error: Error, info: { componentStack: string }): void { + console.error("[ErrorBoundary] Vue plantée :", error, info.componentStack); + } + + render() { + if (this.state.error) { + return ( +
+

Cette vue a rencontré une erreur

+

{this.state.error.message}

+ +
+ ); + } + return this.props.children; + } +} diff --git a/ihm-v2/src/components/common/MarkdownRenderer.tsx b/ihm-v2/src/components/common/MarkdownRenderer.tsx new file mode 100644 index 0000000..1f04e44 --- /dev/null +++ b/ihm-v2/src/components/common/MarkdownRenderer.tsx @@ -0,0 +1,161 @@ +/** + * Rendu Markdown avec coloration syntaxique pour les réponses du moteur. + * + * - react-markdown + remark-gfm (tables, listes de tâches, liens) + * - rehype-highlight + highlight.js subset (yaml, python, bash, json, cpp, diff) + * - Styles dark inline cohérents avec la palette Tailwind slate + */ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeHighlight from "rehype-highlight"; +import { useEffect, useState, useRef } from "react"; + +// Importe uniquement les langages utiles pour la domotique (evite +300 kB) +import "highlight.js/lib/languages/yaml"; +import "highlight.js/lib/languages/python"; +import "highlight.js/lib/languages/bash"; +import "highlight.js/lib/languages/json"; +import "highlight.js/lib/languages/cpp"; +import "highlight.js/lib/languages/diff"; +import "highlight.js/lib/languages/plaintext"; + +// Injecte le thème highlight.js dark une seule fois via un style global +const HLJS_CSS = ` +.hljs { background: transparent; color: #e2e8f0; } +.hljs-comment, .hljs-quote { color: #64748b; font-style: italic; } +.hljs-keyword, .hljs-selector-tag { color: #7dd3fc; } +.hljs-string, .hljs-attr { color: #86efac; } +.hljs-number, .hljs-literal { color: #fbbf24; } +.hljs-title, .hljs-section { color: #c4b5fd; font-weight: 600; } +.hljs-type, .hljs-built_in { color: #f0abfc; } +.hljs-variable { color: #fb923c; } +.hljs-deletion { color: #fca5a5; background: #450a0a40; } +.hljs-addition { color: #86efac; background: #052e1640; } +`; + +let _injected = false; +function injectStyles() { + if (_injected || typeof document === "undefined") return; + const el = document.createElement("style"); + el.textContent = HLJS_CSS; + document.head.appendChild(el); + _injected = true; +} + +// ─── Bloc de code avec bouton copier ───────────────────────────────────────── + +function CodeBlock({ children, ...props }: React.HTMLAttributes) { + const [copied, setCopied] = useState(false); + const preRef = useRef(null); + + const handleCopy = () => { + const text = preRef.current?.innerText ?? ""; + navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1800); + }); + }; + + return ( +
+
+        {children}
+      
+ +
+ ); +} + +// ─── Renderer principal ─────────────────────────────────────────────────────── + +interface Props { + content: string; + className?: string; +} + +export function MarkdownRenderer({ content, className = "" }: Props) { + useEffect(() => { injectStyles(); }, []); + + return ( +
+ {children}; + }, + // Code inline + code({ children, className: cls, ...props }) { + const isBlock = cls?.startsWith("language-"); + if (isBlock) { + return {children}; + } + return ( + + {children} + + ); + }, + // Titres + h1: ({ children }) =>

{children}

, + h2: ({ children }) =>

{children}

, + h3: ({ children }) =>

{children}

, + // Listes + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + // Paragraphe + p: ({ children }) =>

    {children}

    , + // Liens + a: ({ href, children }) => ( + + {children} + + ), + // Séparateur + hr: () =>
    , + // Blockquote + blockquote: ({ children }) => ( +
    + {children} +
    + ), + // Tableaux + table: ({ children }) => ( +
    + {children}
    +
    + ), + th: ({ children }) => ( + + {children} + + ), + td: ({ children }) => ( + {children} + ), + // Strong / em + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + }} + > + {content} +
    +
    + ); +} diff --git a/ihm-v2/src/components/common/RealTimePanel.tsx b/ihm-v2/src/components/common/RealTimePanel.tsx new file mode 100644 index 0000000..99c60ee --- /dev/null +++ b/ihm-v2/src/components/common/RealTimePanel.tsx @@ -0,0 +1,217 @@ +/** + * RealTimePanel — Volet latéral droit de la vue Chat (T84). + * + * Affiche en temps réel (SSE via engineStore) : + * - Statut de connexion + * - Modèle LLM routé + raison (ML Router / Elo / tier) + * - Étapes DAG en cours (agent, tâche) + * - Budget tokens restant (cloud vs local) + */ +import { useEngineStore } from "../../state/engineStore"; +import type { EngineEvent } from "../../types/engine"; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function ConnectionDot({ state }: { state: string }) { + const color = + state === "open" + ? "bg-emerald-400" + : state === "connecting" + ? "bg-amber-400 animate-pulse" + : "bg-red-500"; + const label = state === "open" ? "Connecté" : state === "connecting" ? "Connexion…" : "Déconnecté"; + return ( + + + {label} + + ); +} + +function extractRoutingInfo(events: EngineEvent[]): { model: string; reason: string } | null { + for (const e of events) { + if (e.event === "task_started" || e.event === "agent_started") { + const d = e.data ?? {}; + const model = (d.model_used ?? d.resolved_model ?? d.model_tier ?? "") as string; + const reason = (d.routing_type ?? d.routing_reason ?? d.target_agent ?? "") as string; + if (model || reason) return { model: model || "—", reason: reason || "—" }; + } + } + return null; +} + +function extractActiveSteps(events: EngineEvent[]): { agent: string; task: string }[] { + const started: Record = {}; + const completed = new Set(); + for (const e of [...events].reverse()) { + const d = e.data ?? {}; + const agent = (d.target_agent ?? d.agent_name ?? "") as string; + const task = (d.task_objective ?? "") as string; + if (e.event === "task_completed" || e.event === "agent_completed") { + if (agent) completed.add(agent); + } else if (e.event === "task_started" || e.event === "agent_started") { + if (agent && !completed.has(agent)) started[agent] = task; + } + } + return Object.entries(started).map(([agent, task]) => ({ agent, task })); +} + +function extractBudget(events: EngineEvent[]): { used: number; limit: number } | null { + for (const e of events) { + if (e.event === "tokens_updated" || e.event === "quotas_updated") { + const d = e.data ?? {}; + const used = (d.tokens_used ?? d.total_tokens ?? 0) as number; + const limit = (d.tokens_limit ?? 128_000) as number; + if (used > 0) return { used, limit }; + } + } + return null; +} + +// ─── Composant principal ────────────────────────────────────────────────────── + +interface RealTimePanelProps { + onClose?: () => void; +} + +export function RealTimePanel({ onClose }: RealTimePanelProps) { + const events = useEngineStore((s) => s.events); + const status = useEngineStore((s) => s.status); + const connection = useEngineStore((s) => s.connection); + + const routing = extractRoutingInfo(events); + const steps = extractActiveSteps(events); + const budget = extractBudget(events); + + const recentEvents = events.slice(0, 12); + + return ( + + ); +} diff --git a/ihm-v2/src/components/common/SessionHistory.tsx b/ihm-v2/src/components/common/SessionHistory.tsx new file mode 100644 index 0000000..1a1f324 --- /dev/null +++ b/ihm-v2/src/components/common/SessionHistory.tsx @@ -0,0 +1,138 @@ +/** + * Panneau latéral historique des sessions (/api/sessions). + * + * - Liste des 40 dernières sessions regroupées par jour + * - Clic → recharge l'objectif dans la zone de saisie du Chat (via callback) + * - Indicateurs de statut colorés (success / error / running) + */ +import { useQuery } from "@tanstack/react-query"; +import { fetchSessions, type Session } from "../../api/sessions"; + +// ─── Utilitaires ────────────────────────────────────────────────────────────── + +function formatTime(epoch: number): string { + return new Date(epoch * 1000).toLocaleTimeString("fr-FR", { + hour: "2-digit", + minute: "2-digit", + }); +} + +function dayLabel(epoch: number): string { + const d = new Date(epoch * 1000); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + + if (d.toDateString() === today.toDateString()) return "Aujourd'hui"; + if (d.toDateString() === yesterday.toDateString()) return "Hier"; + return d.toLocaleDateString("fr-FR", { day: "numeric", month: "short" }); +} + +function groupByDay(sessions: Session[]): Map { + const map = new Map(); + for (const s of sessions) { + const label = dayLabel(s.started_at); + const group = map.get(label) ?? []; + group.push(s); + map.set(label, group); + } + return map; +} + +const STATUS_DOT: Record = { + success: "bg-emerald-500", + error: "bg-red-500", + running: "bg-amber-400 animate-pulse", +}; + +// ─── Composant ──────────────────────────────────────────────────────────────── + +interface Props { + onSelectObjective: (objective: string) => void; +} + +export function SessionHistory({ onSelectObjective }: Props) { + const { data, isLoading, isError, refetch } = useQuery({ + queryKey: ["sessions"], + queryFn: () => fetchSessions(40), + refetchInterval: 30_000, + staleTime: 15_000, + }); + + const sessions = data?.sessions ?? []; + const groups = groupByDay(sessions); + + return ( + + ); +} diff --git a/ihm-v2/src/components/common/StatusBadge.tsx b/ihm-v2/src/components/common/StatusBadge.tsx new file mode 100644 index 0000000..223a881 --- /dev/null +++ b/ihm-v2/src/components/common/StatusBadge.tsx @@ -0,0 +1,22 @@ +import type { EngineStatus } from "../../types/engine"; + +const STYLES: Record = { + idle: { label: "Au repos", cls: "bg-slate-700 text-slate-200" }, + running: { label: "En cours", cls: "bg-amber-500/20 text-amber-300 ring-1 ring-amber-500/40" }, + success: { label: "Succès", cls: "bg-emerald-500/20 text-emerald-300 ring-1 ring-emerald-500/40" }, + completed: { label: "Terminé", cls: "bg-emerald-500/20 text-emerald-300 ring-1 ring-emerald-500/40" }, + error: { label: "Erreur", cls: "bg-red-500/20 text-red-300 ring-1 ring-red-500/40" }, + stopped: { label: "Arrêté", cls: "bg-red-500/20 text-red-300 ring-1 ring-red-500/40" }, +}; + +export function StatusBadge({ status }: { status: EngineStatus }) { + const s = STYLES[status] ?? STYLES.idle; + return ( + + {status === "running" && ( + + )} + {s.label} + + ); +} diff --git a/ihm-v2/src/components/openfox_prep/CustomModelModal.tsx b/ihm-v2/src/components/openfox_prep/CustomModelModal.tsx new file mode 100644 index 0000000..04b1538 --- /dev/null +++ b/ihm-v2/src/components/openfox_prep/CustomModelModal.tsx @@ -0,0 +1,317 @@ +import React, { useState } from 'react'; +import { Activity, Check, Plus, Server, X, AlertCircle } from 'lucide-react'; +import { apiFetch } from '../../api/client'; + +export interface CustomModelFormData { + id: string; + provider: string; + name: string; + context_window: number; + input_cost_per_m: number; + output_cost_per_m: number; + supports_vision: boolean; + supports_reasoning: boolean; +} + +/** Providers locaux — IDs réels de `providers` (models_registry.db) : + * `ollama_local` et `local` (LM Studio). Pas de colonne `is_local` : + * on déduit du provider_id, et on écrit `tier: "local"` à l'enregistrement. */ +function isLocalProvider(provider: string): boolean { + return provider === "ollama_local" || provider === "local"; +} + +/** Options du sélecteur : `value` = `providers.id` (FK upsert_model). */ +const PROVIDER_OPTIONS: { id: string; label: string }[] = [ + { id: "ollama_local", label: "Ollama (Local PC)" }, + { id: "local", label: "LM Studio (Local)" }, + { id: "deepseek", label: "DeepSeek API" }, + { id: "gemini_free", label: "Gemini Free (AI Studio)" }, + { id: "openrouter", label: "OpenRouter (OpenAI-compat)" }, +]; + +interface CustomModelModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: () => void; +} + +export const CustomModelModal: React.FC = ({ + isOpen, + onClose, + onSuccess, +}) => { + const [formData, setFormData] = useState({ + id: '', + provider: 'ollama_local', + name: '', + context_window: 128000, + input_cost_per_m: 0, + output_cost_per_m: 0, + supports_vision: false, + supports_reasoning: false, + }); + + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ + success: boolean; + latencyMs?: number; + error?: string; + } | null>(null); + + const [saving, setSaving] = useState(false); + const [formError, setFormError] = useState(null); + + if (!isOpen) return null; + + const handlePingTest = async () => { + if (!formData.id) { + setFormError('L\'ID du modèle est requis pour effectuer un test.'); + return; + } + setTesting(true); + setTestResult(null); + try { + const res = await apiFetch<{ status: string; ttft_ms?: number; latency_ms?: number; error?: string }>( + `/api/models/${encodeURIComponent(formData.id)}/ping`, + { method: 'POST' } + ); + if (res.status === 'success' || res.ttft_ms || res.latency_ms) { + setTestResult({ + success: true, + latencyMs: res.ttft_ms || res.latency_ms || 120, + }); + } else { + setTestResult({ + success: false, + error: res.error || 'Aucune réponse du provider.', + }); + } + } catch (err: any) { + setTestResult({ + success: false, + error: err.message || 'Échec du test de ping', + }); + } finally { + setTesting(false); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData.id || !formData.name) { + setFormError('Veuillez remplir l\'ID et le nom d\'affichage.'); + return; + } + setSaving(true); + setFormError(null); + try { + // Mapping formulaire → ModelUpdateBody (api/routes/models_admin.py:33) : + // les noms de champs divergent (le modal avait été écrit contre une route + // d'ajout inexistante #T289). L'unique chemin d'écriture du catalogue est + // `POST /api/models/update` (merge-patch). + const body = { + id: formData.id, + provider_id: formData.provider, + display_name: formData.name, + // Locaux → tier "local" (comme seed_models_db) ; sinon défaut "free". + tier: isLocalProvider(formData.provider) ? "local" : "free", + context_input: formData.context_window, + cost_input_per_m: formData.input_cost_per_m, + cost_output_per_m: formData.output_cost_per_m, + // La base stocke supports_* en int 0/1, PAS en booléen. + supports_vision: formData.supports_vision ? 1 : 0, + supports_thinking: formData.supports_reasoning ? 1 : 0, + }; + await apiFetch('/api/models/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + onSuccess(); + onClose(); + } catch (err: any) { + setFormError(err.message || 'Erreur lors de l\'enregistrement du modèle.'); + } finally { + setSaving(false); + } + }; + + return ( +
    +
    + {/* En-tête */} +
    +
    + +

    Ajouter un Modèle Personnalisé

    +
    + +
    + + {/* Formulaire */} +
    + {formError && ( +
    + + {formError} +
    + )} + +
    +
    + + setFormData({ ...formData, id: e.target.value })} + className="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-indigo-500 font-mono" + /> +
    +
    + + +
    +
    + +
    + + setFormData({ ...formData, name: e.target.value })} + className="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-indigo-500" + /> +
    + +
    +
    + + setFormData({ ...formData, context_window: parseInt(e.target.value) || 0 })} + className="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-200 font-mono" + /> +
    +
    + + setFormData({ ...formData, input_cost_per_m: parseFloat(e.target.value) || 0 })} + className="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-200 font-mono" + /> +
    +
    + + setFormData({ ...formData, output_cost_per_m: parseFloat(e.target.value) || 0 })} + className="w-full bg-slate-950 border border-slate-800 rounded-lg px-3 py-2 text-sm text-slate-200 font-mono" + /> +
    +
    + +
    + {/* Localité déduite de providers.id (ollama_local / local) — cf. isLocalProvider. */} + + + {isLocalProvider(formData.provider) ? 'Hôte Local (GPU 0€)' : 'Distant (API cloud)'} + + + + + +
    + +
    + + + {testResult && ( +
    + {testResult.success ? ( + + TTFT: {testResult.latencyMs} ms + + ) : ( + + Injoignable + + )} +
    + )} +
    + +
    + + +
    +
    +
    +
    + ); +}; diff --git a/ihm-v2/src/components/openfox_prep/ThinkingBlock.tsx b/ihm-v2/src/components/openfox_prep/ThinkingBlock.tsx new file mode 100644 index 0000000..e1267a7 --- /dev/null +++ b/ihm-v2/src/components/openfox_prep/ThinkingBlock.tsx @@ -0,0 +1,62 @@ +import React, { useState, useEffect } from 'react'; +import { Brain, ChevronDown, ChevronRight, Clock } from 'lucide-react'; + +interface ThinkingBlockProps { + thinkingText: string; + isStreaming?: boolean; + durationMs?: number; +} + +export const ThinkingBlock: React.FC = ({ + thinkingText, + isStreaming = false, + durationMs, +}) => { + const [isExpanded, setIsExpanded] = useState(isStreaming); + const [timer, setTimer] = useState(0); + + useEffect(() => { + let interval: any; + if (isStreaming) { + interval = setInterval(() => { + setTimer((prev) => prev + 100); + }, 100); + } + return () => clearInterval(interval); + }, [isStreaming]); + + const displayDuration = durationMs ? (durationMs / 1000).toFixed(1) : (timer / 1000).toFixed(1); + + if (!thinkingText && !isStreaming) return null; + + return ( +
    + + + {isExpanded && ( +
    + {thinkingText} + {isStreaming && } +
    + )} +
    + ); +}; diff --git a/ihm-v2/src/components/openfox_prep/ToolCallDisplay.tsx b/ihm-v2/src/components/openfox_prep/ToolCallDisplay.tsx new file mode 100644 index 0000000..8988140 --- /dev/null +++ b/ihm-v2/src/components/openfox_prep/ToolCallDisplay.tsx @@ -0,0 +1,95 @@ +import React, { useState } from 'react'; +import { CheckCircle2, XCircle, Loader2, ChevronDown, ChevronRight, Copy, Check } from 'lucide-react'; + +// [#T291] Le type vit dans la couche état (`state/chatStore`), source unique : +// le store le remplit depuis le SSE, ce composant ne fait que l'afficher. +import type { ToolCallData } from '../../state/chatStore'; + +export type { ToolCallData }; + +interface ToolCallDisplayProps { + toolCall: ToolCallData; +} + +export const ToolCallDisplay: React.FC = ({ toolCall }) => { + const [isExpanded, setIsExpanded] = useState(false); + const [copied, setCopied] = useState(false); + + const handleCopyArgs = (e: React.MouseEvent) => { + e.stopPropagation(); + navigator.clipboard.writeText(JSON.stringify(toolCall.args, null, 2)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
    +
    setIsExpanded(!isExpanded)} + className="px-3.5 py-2.5 bg-slate-900/80 hover:bg-slate-900 flex items-center justify-between cursor-pointer transition-colors" + > +
    + {toolCall.status === 'running' && } + {toolCall.status === 'success' && } + {toolCall.status === 'error' && } + + {toolCall.toolName} + + + {JSON.stringify(toolCall.args)} + +
    + +
    + {toolCall.executionTimeMs && ( + {toolCall.executionTimeMs} ms + )} + + + + {isExpanded ? : } +
    +
    + + {isExpanded && ( +
    +
    + Arguments de l'Outil +
    +              {JSON.stringify(toolCall.args, null, 2)}
    +            
    +
    + + {/* `Boolean(...)` : `resultData` est typé `unknown`, la chaîne `&&` + rendrait sinon un `unknown` que JSX refuse. Même condition qu'avant. */} + {toolCall.status === 'success' && Boolean(toolCall.resultData) && ( +
    + Résultat Renvoyé +
    +                {typeof toolCall.resultData === 'string'
    +                  ? toolCall.resultData
    +                  : JSON.stringify(toolCall.resultData, null, 2)}
    +              
    +
    + )} + + {toolCall.status === 'error' && toolCall.errorMessage && ( +
    + Message d'Erreur +
    + {toolCall.errorMessage} +
    +
    + )} +
    + )} +
    + ); +}; diff --git a/ihm-v2/src/components/openfox_prep/ToolPermissionSelector.tsx b/ihm-v2/src/components/openfox_prep/ToolPermissionSelector.tsx new file mode 100644 index 0000000..206abda --- /dev/null +++ b/ihm-v2/src/components/openfox_prep/ToolPermissionSelector.tsx @@ -0,0 +1,264 @@ +import React, { useState } from 'react'; +import { Shield, ShieldAlert, ShieldCheck, Filter, Wrench, ChevronDown, ChevronRight } from 'lucide-react'; + +export type PermissionMode = 'all' | 'none' | 'partial'; + +export interface MCPToolGroup { + serverId: string; + serverName: string; + tools: { + name: string; + description: string; + }[]; +} + +export const AVAILABLE_MCP_GROUPS: MCPToolGroup[] = [ + { + serverId: 'system', + serverName: 'Système & Fichiers', + tools: [ + { name: 'read_file', description: 'Lecture de fichiers dans le workspace' }, + { name: 'write_file', description: 'Écriture et édition de fichiers' }, + { name: 'run_terminal_command', description: 'Exécution de commandes terminal sandboxées' }, + // [#T290] « git_safety » était un alias conceptuel : aucun outil de ce nom + // n'existe dans le registre (tools/registry_setup.py:74-76). Les vrais noms + // sont les trois checkpoints Git ci-dessous — un nom divergent n'autoriserait RIEN. + { name: 'git_create_checkpoint', description: 'Crée un checkpoint de sécurité Git' }, + { name: 'git_rollback_checkpoint', description: 'Annule des modifications via Git (rollback)' }, + { name: 'git_apply_checkpoint', description: 'Valide un checkpoint Git en fusionnant' }, + ], + }, + { + serverId: 'tab5', + serverName: 'MCP Tab5 Engine', + tools: [ + { name: 'execute_ha_action', description: 'Exécution d\'action domotique HA' }, + { name: 'search_ha_entities', description: 'Recherche d\'entités domotiques' }, + { name: 'query_llm_direct', description: 'Appel direct au hub LLM' }, + { name: 'rag_search', description: 'Recherche RAG vectorielle dans memory.db' }, + ], + }, + { + serverId: 'workspace', + serverName: 'Google Workspace MCP', + tools: [ + { name: 'get_calendar_events', description: 'Lecture du calendrier Google' }, + { name: 'get_tasks', description: 'Gestion des tâches Google Tasks' }, + { name: 'search_gmail', description: 'Recherche dans les emails Gmail' }, + ], + }, + { + serverId: 'memory', + serverName: 'Mémoire Graphe & Épisodique', + tools: [ + { name: 'create_entities', description: 'Ajout de faits dans le graphe relationnel' }, + { name: 'read_graph', description: 'Consultation du graphe de mémoire' }, + { name: 'add_observations', description: 'Enregistrement d\'observations' }, + ], + }, +]; + +interface ToolPermissionSelectorProps { + allowedTools: string[] | null; + onChange: (newAllowedTools: string[] | null) => void; +} + +export const ToolPermissionSelector: React.FC = ({ + allowedTools, + onChange, +}) => { + const getInitialMode = (): PermissionMode => { + if (allowedTools === null) return 'all'; + if (Array.isArray(allowedTools) && allowedTools.length === 0) return 'none'; + return 'partial'; + }; + + const [mode, setMode] = useState(getInitialMode()); + const [selectedTools, setSelectedTools] = useState>( + new Set(Array.isArray(allowedTools) ? allowedTools : []) + ); + + const [searchFilter, setSearchFilter] = useState(''); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); + + const handleModeChange = (newMode: PermissionMode) => { + setMode(newMode); + if (newMode === 'all') { + onChange(null); + } else if (newMode === 'none') { + onChange([]); + } else { + onChange(Array.from(selectedTools)); + } + }; + + const toggleTool = (toolName: string) => { + const next = new Set(selectedTools); + if (next.has(toolName)) { + next.delete(toolName); + } else { + next.add(toolName); + } + setSelectedTools(next); + onChange(Array.from(next)); + }; + + const toggleGroup = (group: MCPToolGroup) => { + const allGroupTools = group.tools.map((t) => t.name); + const hasAll = allGroupTools.every((t) => selectedTools.has(t)); + + const next = new Set(selectedTools); + if (hasAll) { + allGroupTools.forEach((t) => next.delete(t)); + } else { + allGroupTools.forEach((t) => next.add(t)); + } + setSelectedTools(next); + onChange(Array.from(next)); + }; + + const toggleCollapseGroup = (serverId: string) => { + const next = new Set(collapsedGroups); + if (next.has(serverId)) next.delete(serverId); + else next.add(serverId); + setCollapsedGroups(next); + }; + + return ( +
    +
    +
    + + Permissions d'Outils (Moindre Privilège) +
    + #T233 / #T290 +
    + +
    + + + + + +
    + + {mode === 'partial' && ( +
    +
    + + setSearchFilter(e.target.value)} + className="w-full bg-slate-900 border border-slate-800 rounded-lg pl-9 pr-3 py-1.5 text-xs text-slate-200 focus:outline-none focus:border-indigo-500" + /> +
    + +
    + {AVAILABLE_MCP_GROUPS.map((group) => { + const filteredTools = group.tools.filter( + (t) => + t.name.toLowerCase().includes(searchFilter.toLowerCase()) || + t.description.toLowerCase().includes(searchFilter.toLowerCase()) + ); + + if (filteredTools.length === 0) return null; + + const isCollapsed = collapsedGroups.has(group.serverId); + const selectedCount = group.tools.filter((t) => selectedTools.has(t.name)).length; + + return ( +
    +
    + + + +
    + + {!isCollapsed && ( +
    + {filteredTools.map((tool) => { + const isChecked = selectedTools.has(tool.name); + return ( + + ); + })} +
    + )} +
    + ); + })} +
    +
    + )} +
    + ); +}; diff --git a/ihm-v2/src/hooks/useEngineStream.ts b/ihm-v2/src/hooks/useEngineStream.ts new file mode 100644 index 0000000..6ddd274 --- /dev/null +++ b/ihm-v2/src/hooks/useEngineStream.ts @@ -0,0 +1,104 @@ +/** + * Abonnement au flux d'événements global du moteur (GET /api/stream) via SSE. + * + * Particularité : le flux est protégé par require_auth et authentifié par un + * ticket éphémère à USAGE UNIQUE (cf. api/client.buildStreamUrl). Comme un + * EventSource peut se reconnecter tout seul (et réutiliserait alors un ticket + * déjà consommé → 401), on gère la reconnexion à la main : à chaque coupure on + * ferme, on attend un court backoff, puis on redemande un ticket frais. + */ +import { useEffect } from "react"; +import { buildStreamUrl, isAuthError } from "../api/client"; +import { useEngineStore } from "../state/engineStore"; +import type { EngineEvent } from "../types/engine"; + +interface Options { + /** Désactive l'abonnement (ex: tant qu'aucune clé API n'est saisie). */ + enabled?: boolean; +} + +const RECONNECT_MS = 2000; + +export function useEngineStream({ enabled = true }: Options = {}): void { + const applyEvent = useEngineStore((s) => s.applyEvent); + const setConnection = useEngineStore((s) => s.setConnection); + + useEffect(() => { + if (!enabled) return; + + let source: EventSource | null = null; + let reconnectTimer: ReturnType | null = null; + let cancelled = false; + + const handle = (raw: MessageEvent, eventName: string) => { + let parsed: Record = {}; + try { + parsed = raw.data ? JSON.parse(raw.data) : {}; + } catch { + parsed = { raw: raw.data }; + } + const evt: EngineEvent = { + event: (parsed.event as string) ?? eventName, + data: (parsed.data as Record) ?? parsed, + engine_state: (parsed.engine_state as EngineEvent["engine_state"]) ?? null, + status: parsed.status as EngineEvent["status"], + receivedAt: Date.now(), + }; + applyEvent(evt); + }; + + // Types d'événements nommés émis par le bus (api/routes/streaming.py + spec §1). + const NAMED = [ + "ping", + "agent_started", + "agent_completed", + "task_started", + "task_completed", + "orchestration_completed", + "quotas_updated", + "apis_status_updated", + "tokens_updated", + "approval_required", + "approval_user_action", + ]; + + async function connect() { + if (cancelled) return; + setConnection("connecting"); + try { + const url = await buildStreamUrl("/api/stream"); + if (cancelled) return; + source = new EventSource(url); + + source.onopen = () => setConnection("open"); + // Événements génériques (sans champ `event:` nommé). + source.onmessage = (e) => handle(e, "message"); + for (const name of NAMED) { + source.addEventListener(name, (e) => handle(e as MessageEvent, name)); + } + source.onerror = () => { + // EventSource tenterait de réutiliser un ticket consommé → on reprend la main. + source?.close(); + source = null; + setConnection("closed"); + if (!cancelled) reconnectTimer = setTimeout(connect, RECONNECT_MS); + }; + } catch (err) { + setConnection("closed"); + if (isAuthError(err)) { + // apiFetch (dans buildStreamUrl) a déjà notifié le handler global d'auth. + return; // pas de reconnexion en boucle sur une clé invalide + } + if (!cancelled) reconnectTimer = setTimeout(connect, RECONNECT_MS); + } + } + + void connect(); + + return () => { + cancelled = true; + if (reconnectTimer) clearTimeout(reconnectTimer); + source?.close(); + }; + }, [enabled, applyEvent, setConnection]); +} diff --git a/ihm-v2/src/index.css b/ihm-v2/src/index.css new file mode 100644 index 0000000..4207c2c --- /dev/null +++ b/ihm-v2/src/index.css @@ -0,0 +1,19 @@ +@import "tailwindcss"; + +/* Thème sombre par défaut (outil LAN, dashboard temps réel). */ +:root { + color-scheme: dark; +} + +html, +body, +#root { + height: 100%; +} + +body { + margin: 0; + background-color: #0a0a0b; + color: #e5e7eb; + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} diff --git a/ihm-v2/src/lib/dotpath.ts b/ihm-v2/src/lib/dotpath.ts new file mode 100644 index 0000000..e9d000f --- /dev/null +++ b/ihm-v2/src/lib/dotpath.ts @@ -0,0 +1,29 @@ +/** Lecture/écriture immuable par chemin pointé (ex: "persistent_agents.daemon_enabled"). */ + +export function getByPath(obj: unknown, path: string): unknown { + return path.split(".").reduce((acc, key) => { + if (acc && typeof acc === "object" && key in (acc as Record)) { + return (acc as Record)[key]; + } + return undefined; + }, obj); +} + +/** Retourne une copie de `obj` avec la valeur posée au chemin (crée les niveaux manquants). */ +export function setByPath>( + obj: T, + path: string, + value: unknown, +): T { + const keys = path.split("."); + const root: Record = { ...obj }; + let cursor = root; + for (let i = 0; i < keys.length - 1; i++) { + const key = keys[i]; + const next = cursor[key]; + cursor[key] = next && typeof next === "object" ? { ...(next as object) } : {}; + cursor = cursor[key] as Record; + } + cursor[keys[keys.length - 1]] = value; + return root as T; +} diff --git a/ihm-v2/src/state/chatStore.ts b/ihm-v2/src/state/chatStore.ts new file mode 100644 index 0000000..54e9b0d --- /dev/null +++ b/ihm-v2/src/state/chatStore.ts @@ -0,0 +1,256 @@ +/** Store Zustand pour la vue Chat avec persistance backend (T83). */ +import { create } from "zustand"; +import { fetchChatMessages, saveChatMessages } from "../api/sessions"; + +export type MessageRole = "user" | "assistant"; +export type MessageStatus = "streaming" | "done" | "error"; + +export interface AgentStep { + agent: string; + status: string; +} + +/** + * [#T291] Appel d'outil tel que le backend le décrit sur le flux SSE + * (`core/vocal_tools.py:569,579`, contrat documenté dans + * `services/pipeline_service.py:1114-1118`). Défini ici, dans la couche état : + * `ToolCallDisplay` l'importe au lieu d'en tenir une copie. + */ +export interface ToolCallData { + id: string; + toolName: string; + args: Record; + status: "running" | "success" | "error"; + resultData?: unknown; + errorMessage?: string; + executionTimeMs?: number; +} + +/** + * [#T291] Ce que porte `tool_result` — les arguments n'y sont PAS repris. Type + * distinct pour que la fusion ne puisse pas écraser les `args` de l'annonce. + */ +export interface ToolCallResult { + id: string; + toolName: string; + status: "success" | "error"; + resultData?: unknown; + errorMessage?: string; + executionTimeMs?: number; +} + +export interface ChatMessage { + id: string; + role: MessageRole; + content: string; + status: MessageStatus; + agentsUsed?: string[]; + steps?: AgentStep[]; + error?: string; + createdAt: number; + /** [#T291] Raisonnement du modèle (événements `thinking` / `thinking_done`). */ + thinkingText?: string; + thinkingStreaming?: boolean; + thinkingDurationMs?: number; + /** [#T291] Appels d'outils annoncés (`tool_call`) puis résolus (`tool_result`). */ + toolCalls?: ToolCallData[]; +} + +/** [#T194] Tiers réels du backend (get_provider_for_tier) + "auto" (cascade). */ +export type Tier = "leger" | "moyen" | "fort" | "auto"; + +// UUID v4 persisté en localStorage — identifie la session chat courante. +function getOrCreateChatSessionId(): string { + const key = "chat_session_id"; + const existing = localStorage.getItem(key); + if (existing) return existing; + const id = crypto.randomUUID(); + localStorage.setItem(key, id); + return id; +} + +interface ChatStore { + messages: ChatMessage[]; + streaming: boolean; + tier: Tier; + chatSessionId: string; + hydrated: boolean; // true après chargement depuis le backend + setTier: (t: Tier) => void; + addUserMessage: (content: string) => string; + addAssistantMessage: () => string; + appendStep: (id: string, step: AgentStep) => void; + appendThinking: (id: string, chunk: string) => void; + finishThinking: (id: string, durationMs?: number) => void; + startToolCall: (id: string, call: ToolCallData) => void; + resolveToolCall: (id: string, result: ToolCallResult) => void; + setContent: (id: string, content: string) => void; + finalizeMessage: (id: string, content: string, agentsUsed: string[]) => void; + failMessage: (id: string, error: string) => void; + setStreaming: (v: boolean) => void; + clearHistory: () => void; + newSession: () => void; + loadFromBackend: () => Promise; + persistMessage: (msg: ChatMessage) => void; +} + +let _seq = 0; +const uid = () => `msg_${Date.now()}_${++_seq}`; + +export const useChatStore = create((set, get) => ({ + messages: [], + streaming: false, + tier: "auto", + chatSessionId: getOrCreateChatSessionId(), + hydrated: false, + + setTier: (tier) => set({ tier }), + + addUserMessage: (content) => { + const id = uid(); + const msg: ChatMessage = { id, role: "user", content, status: "done", createdAt: Date.now() }; + set((s) => ({ messages: [...s.messages, msg] })); + get().persistMessage(msg); + return id; + }, + + addAssistantMessage: () => { + const id = uid(); + set((s) => ({ + messages: [ + ...s.messages, + { id, role: "assistant", content: "", status: "streaming", steps: [], createdAt: Date.now() }, + ], + })); + return id; + }, + + appendStep: (id, step) => + set((s) => ({ + messages: s.messages.map((m) => + m.id === id ? { ...m, steps: [...(m.steps ?? []), step] } : m + ), + })), + + /** + * [#T291] Concatène un fragment de raisonnement. Le texte est tenu à part du + * `content` : il ne doit jamais se retrouver dans la réponse rendue. + */ + appendThinking: (id, chunk) => + set((s) => ({ + messages: s.messages.map((m) => + m.id === id + ? { ...m, thinkingText: (m.thinkingText ?? "") + chunk, thinkingStreaming: true } + : m + ), + })), + + /** [#T291] Fin de la phase de raisonnement — fige la durée annoncée. */ + finishThinking: (id, durationMs) => + set((s) => ({ + messages: s.messages.map((m) => + m.id === id + ? { ...m, thinkingStreaming: false, thinkingDurationMs: durationMs ?? m.thinkingDurationMs } + : m + ), + })), + + /** [#T291] Annonce d'un appel d'outil (statut « running »). */ + startToolCall: (id, call) => + set((s) => ({ + messages: s.messages.map((m) => + m.id === id ? { ...m, toolCalls: [...(m.toolCalls ?? []), call] } : m + ), + })), + + /** + * [#T291] Résultat d'un outil : remplace l'appel de même `id`. Si l'annonce + * n'a pas été vue (événement perdu), le résultat est ajouté tel quel plutôt + * que jeté silencieusement. + */ + resolveToolCall: (id, result) => + set((s) => ({ + messages: s.messages.map((m) => { + if (m.id !== id) return m; + const calls = m.toolCalls ?? []; + const index = calls.findIndex((c) => c.id === result.id); + if (index === -1) return { ...m, toolCalls: [...calls, { ...result, args: {} }] }; + // `args` vient de l'annonce et n'est pas dans le résultat : la fusion le + // conserve au lieu de le vider. + const fusionne = { ...calls[index], ...result }; + return { ...m, toolCalls: calls.map((c, i) => (i === index ? fusionne : c)) }; + }), + })), + + setContent: (id, content) => + set((s) => ({ + messages: s.messages.map((m) => (m.id === id ? { ...m, content } : m)), + })), + + finalizeMessage: (id, content, agentsUsed) => { + set((s) => ({ + messages: s.messages.map((m) => + // [#T291] `thinkingStreaming: false` : un flux clos ne laisse jamais le + // bloc de raisonnement en « Pensée en cours… ». + m.id === id ? { ...m, content, agentsUsed, status: "done", thinkingStreaming: false } : m + ), + })); + // Persister le message finalisé + const finalized = get().messages.find((m) => m.id === id); + if (finalized) get().persistMessage({ ...finalized, content, agentsUsed, status: "done" }); + }, + + failMessage: (id, error) => + set((s) => ({ + messages: s.messages.map((m) => + m.id === id ? { ...m, error, status: "error", thinkingStreaming: false } : m + ), + })), + + setStreaming: (streaming) => set({ streaming }), + + clearHistory: () => set({ messages: [] }), + + newSession: () => { + const id = crypto.randomUUID(); + localStorage.setItem("chat_session_id", id); + set({ messages: [], chatSessionId: id, hydrated: true }); + }, + + loadFromBackend: async () => { + const { chatSessionId } = get(); + try { + const msgs = await fetchChatMessages(chatSessionId); + if (msgs.length === 0) { + set({ hydrated: true }); + return; + } + const restored: ChatMessage[] = msgs.map((m) => ({ + id: `persisted_${m.id}`, + role: m.role as MessageRole, + content: m.content, + status: "done" as MessageStatus, + agentsUsed: m.agents_used ?? undefined, + createdAt: m.created_at * 1000, + })); + set({ messages: restored, hydrated: true }); + } catch { + // backend indisponible — on continue sans persistance + set({ hydrated: true }); + } + }, + + persistMessage: (msg) => { + const { chatSessionId, messages } = get(); + const firstUserMsg = messages.find((m) => m.role === "user"); + const title = firstUserMsg?.content?.slice(0, 60) ?? undefined; + saveChatMessages(chatSessionId, { + messages: [{ + role: msg.role, + content: msg.content, + agents_used: msg.agentsUsed, + created_at: msg.createdAt / 1000, + }], + title, + }).catch(() => { /* silencieux si backend down */ }); + }, +})); diff --git a/ihm-v2/src/state/engineStore.ts b/ihm-v2/src/state/engineStore.ts new file mode 100644 index 0000000..f9d4f7d --- /dev/null +++ b/ihm-v2/src/state/engineStore.ts @@ -0,0 +1,74 @@ +/** + * Store global de l'état moteur (Zustand). + * + * Centralise l'état d'exécution + le flux d'événements SSE, alimenté à la fois + * par le snapshot REST (GET /api/status) et par les événements temps réel + * (GET /api/stream). Les vues s'abonnent via des sélecteurs ciblés. + */ +import { create } from "zustand"; +import type { EngineEvent, EngineStatus, ExecutionState, GlobalState } from "../types/engine"; + +export type ConnectionState = "connecting" | "open" | "closed"; + +const MAX_EVENTS = 200; + +interface EngineStore { + status: EngineStatus; + objective: string | null; + sessionId: string | null; + engineState: GlobalState | null; + errorMessage: string | null; + events: EngineEvent[]; + connection: ConnectionState; + + /** Remplace l'état depuis un snapshot REST (/api/status). */ + setSnapshot: (s: ExecutionState) => void; + /** Applique un événement SSE temps réel et l'ajoute au journal. */ + applyEvent: (e: EngineEvent) => void; + setConnection: (c: ConnectionState) => void; + clearEvents: () => void; +} + +/** Statuts terminaux d'une orchestration. */ +function statusFromEvent(e: EngineEvent, current: EngineStatus): EngineStatus { + if (typeof e.status === "string") return e.status; + if (e.event === "orchestration_completed") { + const s = (e.data?.status as EngineStatus) ?? "success"; + return s; + } + if (e.event === "agent_started" || e.event === "task_started") return "running"; + return current; +} + +export const useEngineStore = create((set) => ({ + status: "idle", + objective: null, + sessionId: null, + engineState: null, + errorMessage: null, + events: [], + connection: "connecting", + + setSnapshot: (s) => + set({ + status: s.status ?? "idle", + objective: s.objective ?? null, + sessionId: s.session_id ?? null, + engineState: s.engine_state ?? null, + errorMessage: s.error_message ?? null, + }), + + applyEvent: (e) => + set((state) => ({ + status: statusFromEvent(e, state.status), + engineState: e.engine_state ?? state.engineState, + errorMessage: + e.event === "orchestration_completed" && e.data?.error_message + ? String(e.data.error_message) + : state.errorMessage, + events: [e, ...state.events].slice(0, MAX_EVENTS), + })), + + setConnection: (connection) => set({ connection }), + clearEvents: () => set({ events: [] }), +})); diff --git a/ihm-v2/src/types/engine.ts b/ihm-v2/src/types/engine.ts new file mode 100644 index 0000000..a70fa45 --- /dev/null +++ b/ihm-v2/src/types/engine.ts @@ -0,0 +1,70 @@ +/** + * Types du contrat moteur ↔ IHM. + * + * Reflètent les modèles Pydantic / sérialiseurs du backend : + * - execution_state → core/app_state.py (GET /api/status) + * - GlobalState sérialisé → core/serializers.py:global_state_to_dict + * - StateUpdate / TaskPayload → core/state.py + * + * À terme, ces types seront générés automatiquement depuis les schémas Pydantic + * (shared/schemas.py, cf. spec §1). Pour l'instant ils sont maintenus à la main. + */ + +export type EngineStatus = + | "idle" + | "running" + | "success" + | "error" + | "completed" + | "stopped"; + +export interface TaskPayload { + task_objective: string; + relevant_context?: string; + available_tools?: string[]; + metadata?: Record; + task_id?: string; + depends_on?: string[]; +} + +export interface StateUpdate { + agent_name: string; + status: string; + result_data?: unknown; + next_agent?: string | null; + error_message?: string | null; + new_tasks?: TaskPayload[]; + metadata?: Record; +} + +export interface GlobalState { + session_id?: string; + history?: StateUpdate[]; + current_payload?: TaskPayload | null; + shared_memory?: Record; + task_queue?: TaskPayload[]; + working_memory?: Record; +} + +/** Réponse de GET /api/status (core/app_state.execution_state). */ +export interface ExecutionState { + status: EngineStatus; + objective?: string | null; + session_id?: string | null; + engine_state?: GlobalState | null; + error_message?: string | null; +} + +/** + * Événement SSE du bus global GET /api/stream. + * Format émis : `event: \ndata: ` où le JSON contient au moins + * `event` et `data` (cf. api/routes/streaming.py:sse_stream + broadcast_event). + */ +export interface EngineEvent { + event: string; + data?: Record; + engine_state?: GlobalState | null; + status?: EngineStatus; + /** Horodatage local d'arrivée (ajouté côté client pour l'affichage du journal). */ + receivedAt: number; +} diff --git a/ihm-v2/src/types/observability.ts b/ihm-v2/src/types/observability.ts new file mode 100644 index 0000000..1fe75e4 --- /dev/null +++ b/ihm-v2/src/types/observability.ts @@ -0,0 +1,54 @@ +/** + * Types de la télémétrie moteur (GET /api/metrics/telemetry). + * Reflète la structure agrégée par api/routes/metrics.py (conçue pour Chart.js). + */ + +export type TelemetryPeriod = "1h" | "6h" | "24h" | "7d" | "30d"; + +export interface TimeSeries { + labels: string[]; + tokens: number[]; + costs: number[]; +} + +export interface AgentStat { + agent: string; + total_calls: number; + successes?: number; + total_input?: number; + total_output?: number; + avg_tokens?: number; + success_rate?: number; +} + +export interface ModelStat { + total_tokens: number; + total_calls: number; + cost_usd: number; + avg_elo?: number; + elo_domains?: Record; +} + +export interface Kpis { + total_cost_usd: number; + total_sessions: number; + total_tokens: number; +} + +export interface BudgetForecast { + avg_daily_cost: number; + projected_7d: number; + projected_30d: number; + daily_breakdown?: { day: string; daily_cost: number }[]; +} + +export interface Telemetry { + period: string; + generated_at: string; + time_series: TimeSeries; + agent_stats: AgentStat[]; + model_stats: Record; + routing_stats: Record; + kpis: Kpis; + budget_forecast: BudgetForecast; +} diff --git a/ihm-v2/src/views/APIAccounts.tsx b/ihm-v2/src/views/APIAccounts.tsx index eae6564..a6dd038 100644 --- a/ihm-v2/src/views/APIAccounts.tsx +++ b/ihm-v2/src/views/APIAccounts.tsx @@ -91,7 +91,10 @@ function AccountCard({ account }: { account: ProviderAccount }) {
    Quota RPM - {account.used_rpm} / {account.quota_rpm || "∞"} + {/* `used_rpm` est `number | null` : React n'affiche RIEN pour un null, ce qui + rendait « / ∞ » sans valeur devant. La barre de progression, deux lignes + plus bas, se protégeait déjà avec `?? 0` — pas l'affichage. */} + {account.used_rpm ?? 0} / {account.quota_rpm || "∞"}
    Quota RPD - {account.used_rpd} / {account.quota_rpd || "∞"} + {account.used_rpd ?? 0} / {account.quota_rpd || "∞"}
    - Sync: {account.last_sync} + {/* `last_sync` est nullable : l'étiquette « Sync: » restait seule, sans date, + ce qui se lit comme un champ cassé plutôt que comme « jamais synchronisé ». */} + Sync : {account.last_sync ?? "jamais"} {account.dashboard_url && ( + ) : ( + + cœur + + )} +
    + + {agent.model !== null && agent.source !== "workflow" && ( +
    + + +
    + )} + + {agent.interval_minutes != null && ( +
    + + { + const v = parseInt(e.target.value, 10); + if (Number.isFinite(v) && v > 0 && v !== agent.interval_minutes) { + updateMut.mutate({ interval_minutes: v }); + } + }} + className="w-full rounded-lg border border-slate-700 bg-slate-800 px-3 py-1 text-sm text-slate-200 focus:border-sky-500 focus:outline-none" + /> +
    + )} + + {/* [#T290] Permissions d'outils — agents custom issus de config.json + uniquement. Les custom `source: "workflow"` sont kind=custom aussi, + mais updateAgent → 404 (gestion dans l'éditeur) ; même gate que + Supprimer / sélecteur modèle (`source === "config"`). */} + {agent.kind === "custom" && agent.source === "config" && ( +
    + {isEditingPermissions ? ( +
    + +
    + + +
    +
    + ) : ( +
    +
    + Permissions d'outils : + {(() => { + const at = agent.allowed_tools; + if (at === null || at === undefined) return "tous les outils"; + if (Array.isArray(at) && at.length === 0) return "aucun outil"; + return `${(at as string[]).length} outil(s) (partiel)`; + })()} +
    + +
    + )} +
    + )} + +
    + {actionError &&

    ⚠ {actionError}

    } + {isEditingPrompt ? ( +
    +