From 85536f834be960f638bb4713209c0a1ab02fdb78 Mon Sep 17 00:00:00 2001 From: Axel Duviella Date: Tue, 18 Aug 2026 11:14:40 +0200 Subject: [PATCH 1/3] feat: sync moteur 61021f0 vers le miroir OSS (2026-08-18) Aligne le public sur le prive apres les lots vocaux/domotique du 13-18/08 (cascade Zero-LLM, fast-path Cerebras, temperature par piece, volets unknown, marqueurs accentues, sonde d'hote). Anonymisation : IP LAN -> 192.168.1.10, entites HA living_room/bedroom, meteo weather.home, aucun token. Exemples .env + config.example.json portent les memes optimisations audio/domo (fast_path, concurrence, privacy). Scan oss_secrets_scan : OK. Co-authored-by: Cursor --- .env.example | 43 +- AGENTS.md | 2 +- agents/dreamer_agent.py | 103 +++- agents/executor.py | 34 +- agents/planner.py | 69 ++- api/routes/agents.py | 379 +++++++----- api/routes/agents_crud.py | 10 +- api/routes/context.py | 37 +- api/routes/execution.py | 12 +- api/routes/health.py | 19 +- api/routes/observability.py | 13 + api/routes/openai_proxy.py | 219 +++++-- api/routes/streaming.py | 70 ++- claude_desktop_config.json.example | 2 +- config.example.json | 144 ++++- core/acceptance_contract.py | 218 ++++++- core/app_state.py | 233 +++++++- core/budget_guard.py | 8 +- core/daemon_loop.py | 10 +- core/dag_runner.py | 156 ++++- core/engine.py | 85 ++- core/event_store.py | 46 +- core/fenetres_execution.py | 55 +- core/gemini_native.py | 24 +- core/ha_fuzzy_matcher.py | 36 +- core/ha_tls.py | 24 +- core/ha_url.py | 2 +- core/langfuse_bridge.py | 4 +- core/llm/builtin_providers.py | 109 ++-- core/llm/circuit_breaker.py | 55 +- core/llm/provider_health.py | 164 ++++++ core/llm/providers/deepseek.py | 6 +- core/llm_gateway.py | 197 ++++++- core/mcp_bridge.py | 4 +- core/mcp_tools/orchestrator.py | 2 +- core/ml_router.py | 12 +- core/model_health_probe.py | 240 +++++++- core/models_db.py | 88 +++ core/openai_compat_provider.py | 185 +++++- core/pricing.py | 69 ++- core/quota_alert.py | 53 +- core/quota_collector.py | 13 + core/review_loop.py | 330 ++++++++++- core/router.py | 2 +- core/runtime_db.py | 7 +- core/session_history.py | 194 +++++- core/source_router.py | 2 +- core/token_tracker.py | 19 +- core/version_info.py | 106 ++++ core/vocal_host.py | 109 +++- core/vocal_jobs.py | 2 +- core/vocal_tools.py | 67 +++ core/vocal_tts_cache.py | 49 +- core/watchdog.py | 6 +- deploy/lancer_moteur.bat | 4 +- docs/ARCHITECTURE.md | 4 +- docs/architecture/carte_decision_vocale.md | 31 + docs/travail/CONVENTIONS.md | 2 +- docs/travail/DECISIONS.md | 26 +- docs/travail/TACHES.md | 73 ++- docs/travail/TACHES_archive.md | 51 +- gui_server.py | 21 +- ha_commands.json.example | 14 +- mcp_config.json.example | 29 + memory/embeddings.py | 2 +- memory/rag.py | 4 +- populate_embeddings.py | 28 +- pricing_strategy.json.example | 2 +- scripts/recolte_dreamcoder.py | 264 +++++++++ seed_models_db.py | 70 ++- services/approval_resume_service.py | 188 +++++- services/datetime_query.py | 74 +++ services/execute_service.py | 284 ++++++++- services/ha_state_query.py | 153 ++++- services/ha_weather_query.py | 343 +++++++++++ services/pipeline_service.py | 255 +++++++- static/js/components/model-tooltip.js | 6 +- static/js/tabs/catalog.js | 2 +- static/js/tabs/swarm.js | 2 +- tests/conftest.py | 46 ++ tests/run_benchmark.py | 2 +- tests/test_directml_inference.py | 2 +- tests/test_failover.py | 12 +- tests/test_memory_db_v9.py | 14 +- tests/test_phase3_hmi.py | 39 ++ tests/test_pricing.py | 17 +- tests/test_rag_improvements.py | 2 +- tests/unit/test_ajean_declaration.py | 121 ++++ tests/unit/test_billing_balance_t349.py | 208 +++++++ tests/unit/test_builtin_providers_etape2.py | 4 +- tests/unit/test_bulk_status_libere_sonde.py | 41 ++ tests/unit/test_call_api_port_tls_t329.py | 30 +- tests/unit/test_call_api_token_ha_t332.py | 28 +- .../test_call_api_token_prioritaire_t340.py | 32 +- tests/unit/test_circuit_breaker.py | 131 +++++ tests/unit/test_cleanup_zombie_dag.py | 193 ++++++ ...test_cloture_session_fin_execution_t379.py | 360 ++++++++++++ .../test_contrat_plancher_incident_17_08.py | 555 ++++++++++++++++++ tests/unit/test_daemon_loop.py | 2 +- tests/unit/test_dag_watchdog_configurable.py | 212 +++++++ tests/unit/test_dashscope_provider.py | 23 +- .../test_dreamcoder_fenetres_et_echecs.py | 163 +++++ tests/unit/test_dreamcoder_push_branche.py | 190 ++++++ tests/unit/test_event_store_isolation_t348.py | 119 ++++ ...xecute_chemins_legers_hors_creneau_t344.py | 271 +++++++++ ...st_execute_discussion_hors_creneau_t324.py | 291 +++++++++ tests/unit/test_execution_per_session_t324.py | 184 ++++++ .../unit/test_executor_json_illisible_t343.py | 242 ++++++++ tests/unit/test_gateway_direct_cb.py | 66 ++- tests/unit/test_ha_climate_advanced.py | 9 + .../unit/test_ha_commande_abandonnee_t323.py | 53 +- tests/unit/test_ha_entite_morte_t356.py | 234 ++++++++ tests/unit/test_ha_question_pas_ordre_t363.py | 70 +++ tests/unit/test_ha_script_turn_on_t350.py | 189 ++++++ tests/unit/test_ha_state_query.py | 94 ++- tests/unit/test_ha_tls.py | 6 +- tests/unit/test_ha_url_t330.py | 16 +- tests/unit/test_ha_weather_query.py | 231 ++++++++ tests/unit/test_hitl_risk.py | 26 + tests/unit/test_journal_hygiene.py | 191 ++++++ tests/unit/test_marqueurs_accentues_t383.py | 86 +++ tests/unit/test_model_health_backoff.py | 325 ++++++++++ tests/unit/test_model_health_probe_t333.py | 10 +- tests/unit/test_ollama_deck.py | 12 +- tests/unit/test_openai_compat_max_tokens.py | 353 +++++++++++ tests/unit/test_openai_proxy_streaming.py | 16 +- tests/unit/test_openai_proxy_t343.py | 143 +++++ tests/unit/test_openai_proxy_usage_t357.py | 290 +++++++++ tests/unit/test_ordre_cascade_vocale.py | 46 ++ tests/unit/test_privacy_local_only_t337.py | 198 +++++++ tests/unit/test_provider_health.py | 312 ++++++++++ tests/unit/test_proxy_usage_reel_provider.py | 441 ++++++++++++++ tests/unit/test_quota_alerte_t343.py | 224 +++++++ tests/unit/test_recolte_dreamcoder.py | 133 +++++ tests/unit/test_regression_prod_17_08.py | 308 ++++++++++ .../unit/test_seed_routing_rules_dashscope.py | 33 +- .../test_stream_discussion_outils_t346.py | 341 +++++++++++ ...est_stream_discussion_raisonnement_t346.py | 235 ++++++++ tests/unit/test_stream_discussion_sonde.py | 163 +++++ tests/unit/test_t169_gateway_verdicts.py | 61 +- tests/unit/test_t347_cache_hit_cout.py | 220 +++++++ tests/unit/test_temperature_lieu_t371.py | 89 +++ tests/unit/test_version_info.py | 170 ++++++ tests/unit/test_vocal_audit_couverture.py | 150 +++++ tests/unit/test_vocal_host.py | 136 ++++- tests/unit/test_vocal_tools.py | 4 +- tests/unit/test_vocal_tts_cache.py | 37 ++ tests/unit/test_volet_unknown_t385.py | 146 +++++ tests/unit/test_zone_grise_catalogue.py | 292 +++++++++ tools/api.py | 2 +- tools/audit_catalogue_sante.py | 302 ++++++++++ tools/context_self_healing.py | 20 +- tools/failover_manager.py | 4 +- tools/git_safety.py | 2 +- tools/ha_entity_ingest.py | 6 +- tools/night_orchestrator.py | 8 +- tools/system.py | 6 +- tools/tableau_de_bord.py | 2 +- 158 files changed, 15260 insertions(+), 864 deletions(-) create mode 100644 core/llm/provider_health.py create mode 100644 core/version_info.py create mode 100644 docs/architecture/carte_decision_vocale.md create mode 100644 mcp_config.json.example create mode 100644 scripts/recolte_dreamcoder.py create mode 100644 services/datetime_query.py create mode 100644 services/ha_weather_query.py create mode 100644 tests/unit/test_ajean_declaration.py create mode 100644 tests/unit/test_billing_balance_t349.py create mode 100644 tests/unit/test_bulk_status_libere_sonde.py create mode 100644 tests/unit/test_cleanup_zombie_dag.py create mode 100644 tests/unit/test_cloture_session_fin_execution_t379.py create mode 100644 tests/unit/test_contrat_plancher_incident_17_08.py create mode 100644 tests/unit/test_dag_watchdog_configurable.py create mode 100644 tests/unit/test_dreamcoder_fenetres_et_echecs.py create mode 100644 tests/unit/test_dreamcoder_push_branche.py create mode 100644 tests/unit/test_event_store_isolation_t348.py create mode 100644 tests/unit/test_execute_chemins_legers_hors_creneau_t344.py create mode 100644 tests/unit/test_execute_discussion_hors_creneau_t324.py create mode 100644 tests/unit/test_execution_per_session_t324.py create mode 100644 tests/unit/test_executor_json_illisible_t343.py create mode 100644 tests/unit/test_ha_entite_morte_t356.py create mode 100644 tests/unit/test_ha_question_pas_ordre_t363.py create mode 100644 tests/unit/test_ha_script_turn_on_t350.py create mode 100644 tests/unit/test_ha_weather_query.py create mode 100644 tests/unit/test_journal_hygiene.py create mode 100644 tests/unit/test_marqueurs_accentues_t383.py create mode 100644 tests/unit/test_model_health_backoff.py create mode 100644 tests/unit/test_openai_compat_max_tokens.py create mode 100644 tests/unit/test_openai_proxy_t343.py create mode 100644 tests/unit/test_openai_proxy_usage_t357.py create mode 100644 tests/unit/test_ordre_cascade_vocale.py create mode 100644 tests/unit/test_privacy_local_only_t337.py create mode 100644 tests/unit/test_provider_health.py create mode 100644 tests/unit/test_proxy_usage_reel_provider.py create mode 100644 tests/unit/test_quota_alerte_t343.py create mode 100644 tests/unit/test_recolte_dreamcoder.py create mode 100644 tests/unit/test_regression_prod_17_08.py create mode 100644 tests/unit/test_stream_discussion_outils_t346.py create mode 100644 tests/unit/test_stream_discussion_raisonnement_t346.py create mode 100644 tests/unit/test_stream_discussion_sonde.py create mode 100644 tests/unit/test_t347_cache_hit_cout.py create mode 100644 tests/unit/test_temperature_lieu_t371.py create mode 100644 tests/unit/test_version_info.py create mode 100644 tests/unit/test_vocal_audit_couverture.py create mode 100644 tests/unit/test_volet_unknown_t385.py create mode 100644 tests/unit/test_zone_grise_catalogue.py create mode 100644 tools/audit_catalogue_sante.py 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/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..84ef044 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, build b10451) sur le PC d'Axel, 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, pour l'instance Deck à venir — pas un provider distinct, d'où l'absence de descripteur propre (même logique qu'ollama_local/ollama_pc). NON branché en cascade tant que l'hôte n'écoute pas sur le LAN (action d'Axel : --host 0.0.0.0 + pare-feu 8080).", + 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..d56d18c 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.10", "192.168.1.10"] # Ethernet prioritaire, Wi-Fi fallback def __init__( self, - host: str = "${DECK_HOST:-192.168.1.x}", + host: str = "192.168.1.10", port: int = 11434, model_name: str = "phi3:mini", ): diff --git a/core/llm_gateway.py b/core/llm_gateway.py index 36e55a9..198549a 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 d'Axel (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/mcp_config.json.example b/mcp_config.json.example new file mode 100644 index 0000000..90c4f27 --- /dev/null +++ b/mcp_config.json.example @@ -0,0 +1,29 @@ +{ + "_commentaire": [ + "mcp_config.json.example — Modèle de configuration MCPBridge (#T213).", + "Copier vers mcp_config.json (gitignoré, propre à chaque machine) pour que", + "core/mcp_bridge.py démarre des serveurs MCP tiers dans le pipeline HTTP.", + "Ordre de résolution de MCPBridge : ~/.gemini/antigravity-ide/mcp_config.json,", + "puis /mcp_config.json, puis /config/moteur-master/mcp_config.json.", + "Sans fichier, le pont se désactive proprement (warning, aucun outil MCP).", + "SÉCURITÉ : ne JAMAIS mettre de secret ici — MCPBridge fusionne os.environ", + "dans l'env de chaque serveur, les tokens (GITHUB_TOKEN, HASS_TOKEN...)", + "doivent rester dans le .env du moteur.", + "Le filtrage des serveurs réellement démarrés par requête est piloté par", + "mcp_activation_rules.json (défaut + mots-clés du prompt)." + ], + "mcpServers": { + "filesystem": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/workspace"] + }, + "memory": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-memory"] + }, + "github": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-github"] + } + } +} diff --git a/memory/embeddings.py b/memory/embeddings.py index 194355a..feb3c2d 100644 --- a/memory/embeddings.py +++ b/memory/embeddings.py @@ -134,7 +134,7 @@ def index_documents(self, doc_dir: str = None, force_reindex: bool = False) -> i return 0 if doc_dir is None: - # Remonter au dossier e:\AuxFilsDesIdees\contexte_ia + # Remonter au dossier .\contexte_ia base_dir = os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) diff --git a/memory/rag.py b/memory/rag.py index 68c3202..e4f9511 100644 --- a/memory/rag.py +++ b/memory/rag.py @@ -26,8 +26,8 @@ class RAGEngine: """ def __init__(self, doc_dir: str = None): if doc_dir is None: - # Recherche relative du dossier contexte_ia à partir de e:\AuxFilsDesIdees\moteur_agents\memory\rag.py - # 3 niveaux au-dessus : e:\AuxFilsDesIdees + # Recherche relative du dossier contexte_ia à partir de .\memory\rag.py + # 3 niveaux au-dessus : . base_dir = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) self.doc_dir = os.path.join(base_dir, "contexte_ia") else: diff --git a/populate_embeddings.py b/populate_embeddings.py index 39d8569..8df23ea 100644 --- a/populate_embeddings.py +++ b/populate_embeddings.py @@ -41,20 +41,20 @@ # Fichiers de contexte à indexer (les 3-Layers + leçons thématiques) FILES_TO_EMBED = [ - r"e:\AuxFilsDesIdees\contexte_ia\01_Core\rules_global.md", - r"e:\AuxFilsDesIdees\contexte_ia\01_Core\hardware_pc_ia_locale.md", - r"e:\AuxFilsDesIdees\contexte_ia\01_Core\lecons_infra_windows.md", - r"e:\AuxFilsDesIdees\contexte_ia\02_Hardware\rules_esphome.md", - r"e:\AuxFilsDesIdees\contexte_ia\02_Hardware\02_MATERIEL_ET_ECRANS.md", - r"e:\AuxFilsDesIdees\contexte_ia\02_Hardware\lecons_esphome_hardware.md", - r"e:\AuxFilsDesIdees\contexte_ia\03_Software\rules_home_assistant.md", - r"e:\AuxFilsDesIdees\contexte_ia\03_Software\rules_moteur_agents.md", - r"e:\AuxFilsDesIdees\contexte_ia\03_Software\01_SERVEUR_HA.md", - r"e:\AuxFilsDesIdees\contexte_ia\03_Software\03_LOGIQUE_ET_APIS.md", - r"e:\AuxFilsDesIdees\contexte_ia\03_Software\05_MOTEUR_AGENTS_PYTHON.md", - r"e:\AuxFilsDesIdees\contexte_ia\03_Software\lecons_moteur_agents.md", - r"e:\AuxFilsDesIdees\contexte_ia\03_Software\lecons_gcp_apis.md", - r"e:\AuxFilsDesIdees\contexte_ia\03_Software\lecons_hmi.md", + r".\contexte_ia\01_Core\rules_global.md", + r".\contexte_ia\01_Core\hardware_pc_ia_locale.md", + r".\contexte_ia\01_Core\lecons_infra_windows.md", + r".\contexte_ia\02_Hardware\rules_esphome.md", + r".\contexte_ia\02_Hardware\02_MATERIEL_ET_ECRANS.md", + r".\contexte_ia\02_Hardware\lecons_esphome_hardware.md", + r".\contexte_ia\03_Software\rules_home_assistant.md", + r".\contexte_ia\03_Software\rules_moteur_agents.md", + r".\contexte_ia\03_Software\01_SERVEUR_HA.md", + r".\contexte_ia\03_Software\03_LOGIQUE_ET_APIS.md", + r".\contexte_ia\03_Software\05_MOTEUR_AGENTS_PYTHON.md", + r".\contexte_ia\03_Software\lecons_moteur_agents.md", + r".\contexte_ia\03_Software\lecons_gcp_apis.md", + r".\contexte_ia\03_Software\lecons_hmi.md", ] # Taille maximale d'un chunk (en caractères) pour l'indexation diff --git a/pricing_strategy.json.example b/pricing_strategy.json.example index b48087c..1d3789d 100644 --- a/pricing_strategy.json.example +++ b/pricing_strategy.json.example @@ -554,6 +554,6 @@ "qwen3-coder-next", "qwen3.6-35b-a3b" ], - "advantages": "RTX 5070 Ti (16GB VRAM), 9 modèles chargés. Coût zéro, confidentialité totale." + "advantages": "GPU locale, modèles chargés. Coût zéro, confidentialité totale." } } \ No newline at end of file diff --git a/scripts/recolte_dreamcoder.py b/scripts/recolte_dreamcoder.py new file mode 100644 index 0000000..81ef274 --- /dev/null +++ b/scripts/recolte_dreamcoder.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +""" +scripts/recolte_dreamcoder.py — Récolte le travail de nuit de DreamCoder. + +Le Deck exécute les tâches du backlog et commite chacune sur une branche +`task/*` de son clone dédié. Il ne peut pas les publier lui-même : sa clé de +déploiement GitHub est en LECTURE SEULE (`~/.ssh/config` du Deck, 07/08). Ce +script fait la dernière marche depuis le PC, où `gh` est authentifié : + + clone du Deck --(fetch ssh)--> dépôt local --(push)--> GitHub --> PR + +Il ne touche JAMAIS le répertoire de travail local : pas de checkout, pas de +merge, pas de stash. Tout passe par des refspecs explicites, parce qu'Axel +travaille dans ce dépôt pendant que la récolte tourne. + +Usage : + python scripts/recolte_dreamcoder.py --dry-run # ce qui serait publié + python scripts/recolte_dreamcoder.py # push + PR +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys + +# ── Valeurs par défaut, alignées sur l'installation réelle ─────────────────── +DECK_HOTE = "deck@192.168.1.10" +DECK_CLONE = "/opt/vromvrom-engine_dreamcoder" +DECK_PROD = "/opt/vromvrom-engine" # porte les rapports de tâche +CLE_DECK = os.path.expanduser("~/.ssh/id_ed25519") + +# `task/_` — cf. git_prepare_agent_branch(prefix="task/") +_RE_BRANCHE = re.compile(r"^task/(\d+)_\d+$") + + +def _run(args: list[str], cwd: str | None = None, env: dict | None = None, + timeout: float = 120.0) -> tuple[int, str, str]: + """Exécute une commande sans shell. Retourne (code, stdout, stderr).""" + try: + res = subprocess.run(args, cwd=cwd, env=env, capture_output=True, + text=True, timeout=timeout, encoding="utf-8", + errors="replace") + return res.returncode, (res.stdout or "").strip(), (res.stderr or "").strip() + except subprocess.TimeoutExpired: + return -1, "", f"timeout après {timeout}s" + except Exception as e: + return -1, "", str(e) + + +def _env_ssh(cle: str) -> dict: + """ + Env qui force la clé du Deck pour les transports git. + + Sans ça, git prend la clé par défaut de l'agent et se fait refuser + (`Permission denied (publickey)`) — vérifié sur ce poste. + """ + return { + **os.environ, + "GIT_SSH_COMMAND": f'ssh -i "{cle}" -o StrictHostKeyChecking=no -o BatchMode=yes', + "GIT_TERMINAL_PROMPT": "0", + } + + +def _ssh_deck(hote: str, cle: str, commande: str, + timeout: float = 60.0) -> tuple[int, str, str]: + """Exécute une commande sur le Deck (lecture seule dans ce script).""" + return _run(["ssh", "-i", cle, "-o", "StrictHostKeyChecking=no", + "-o", "BatchMode=yes", "-o", "ConnectTimeout=10", hote, commande], + timeout=timeout) + + +def lister_branches_taches(hote: str, cle: str, clone: str, base: str) -> list[dict]: + """ + Branches `task/*` du clone Deck qui portent du travail non encore dans `base`. + + Le filtre se fait CÔTÉ DECK (`git branch --no-merged`) : une branche déjà + fusionnée dans master n'a plus rien à publier, et la récolte doit être + rejouable sans créer de doublons. + + :raises RuntimeError: si le Deck est injoignable — l'appelant doit pouvoir + distinguer « rien produit cette nuit » de « je n'ai pas pu regarder ». + """ + # Les quotes sont indispensables : `%(refname:short)` non protégé est + # interprété par le shell distant ("syntax error near unexpected token `('"). + commande = (f"cd {clone} && git branch --no-merged {base} " + f"--format='%(refname:short)'") + code, sortie, err = _ssh_deck(hote, cle, commande) + if code != 0: + # Une panne de lecture n'est PAS « rien à récolter » : rendre 0 ici + # ferait passer un Deck injoignable pour une nuit sans production. + raise RuntimeError(f"lecture des branches impossible : {err or sortie}") + + branches = [] + for ligne in sortie.splitlines(): + nom = ligne.strip() + m = _RE_BRANCHE.match(nom) + if m: + branches.append({"branche": nom, "task_id": int(m.group(1))}) + return branches + + +def lire_rapport_tache(hote: str, cle: str, prod: str, task_id: int) -> dict: + """ + Rapport écrit par DreamCoder à la fin de la tâche (titre, résumé, coût). + + Il vit dans le runtime de PROD, pas dans le clone : `_ENGINE_ROOT` reste le + dossier du serveur même quand le travail Git se fait ailleurs (#T202). + Absent = non bloquant, la PR sera juste plus maigre. + """ + chemin = f"{prod}/checkpoints/dreamcoder_results/task_{task_id}.json" + code, sortie, _ = _ssh_deck(hote, cle, f"cat {chemin} 2>/dev/null") + if code != 0 or not sortie: + return {} + try: + return json.loads(sortie) + except json.JSONDecodeError: + return {} + + +def corps_de_pr(task_id: int, rapport: dict) -> str: + """Corps de PR : ce que l'agent a fait, ce que ça a coûté, ce qu'il reste à vérifier.""" + titre = rapport.get("title", "(titre indisponible)") + resume = (rapport.get("summary") or "(pas de résumé)").strip() + cout = rapport.get("cost_usd") + jetons = rapport.get("tokens_used") + cout_texte = f"${cout:.4f}" if isinstance(cout, (int, float)) else "inconnu" + + lignes = [ + "## Tâche autonome DreamCoder", + "", + f"**Tâche #{task_id}** — {titre}", + "", + "### Ce que l'agent rapporte", + "", + resume[:4000], + "", + "### Exécution", + "", + f"- Coût : {cout_texte}", + f"- Jetons : {jetons if jetons is not None else 'inconnu'}", + "- Produit sur le Deck, hors surveillance humaine.", + "", + "### À vérifier avant merge", + "", + "- [ ] Le diff fait ce que la tâche demandait", + "- [ ] `pytest tests/unit` au vert en local (la CI GitHub est morte, #T382)", + "- [ ] Aucun secret, aucune base, aucun fichier de runtime dans le diff", + "", + "🤖 Récolté par `scripts/recolte_dreamcoder.py`", + ] + return "\n".join(lignes) + + +def pr_existante(depot: str, branche: str) -> str | None: + """URL d'une PR déjà ouverte pour cette branche, sinon None (récolte rejouable).""" + code, sortie, _ = _run(["gh", "pr", "list", "--head", branche, "--state", "all", + "--json", "url", "--limit", "1"], cwd=depot) + if code != 0 or not sortie: + return None + try: + lot = json.loads(sortie) + return lot[0]["url"] if lot else None + except (json.JSONDecodeError, KeyError, IndexError): + return None + + +def recolter(args) -> int: + """Récolte complète : liste, fetch, push, PR. Retourne un code de sortie.""" + env = _env_ssh(args.cle) + url_deck = f"{args.hote}:{args.clone}" + + try: + branches = lister_branches_taches(args.hote, args.cle, args.clone, args.base) + except RuntimeError as e: + print(f"❌ {e}", file=sys.stderr) + return 1 + + if not branches: + print("Aucune branche task/* à récolter — " + "le backlog n'a rien produit depuis la dernière fois.") + return 0 + + print(f"{len(branches)} branche(s) de tâche trouvée(s) sur le Deck :") + for b in branches: + print(f" · {b['branche']}") + print() + + # Un seul fetch pour tout le lot, dans un espace de refs dédié : le dépôt + # local d'Axel ne voit apparaître ni branche locale, ni modification d'index. + code, _, err = _run(["git", "fetch", url_deck, + "+refs/heads/task/*:refs/remotes/deck-dreamcoder/task/*"], + cwd=args.depot, env=env, timeout=300.0) + if code != 0: + print(f"❌ Fetch du clone Deck impossible : {err}", file=sys.stderr) + return 1 + + echecs = 0 + for b in branches: + branche, task_id = b["branche"], b["task_id"] + + deja = pr_existante(args.depot, branche) + if deja: + print(f"⏭️ {branche} — PR déjà ouverte : {deja}") + continue + + rapport = lire_rapport_tache(args.hote, args.cle, args.prod, task_id) + titre_pr = rapport.get("title") or f"Tâche autonome #{task_id}" + + if args.dry_run: + print(f"[dry-run] pousserait {branche} et ouvrirait la PR « {titre_pr} »") + continue + + code, _, err = _run(["git", "push", args.remote, + f"refs/remotes/deck-dreamcoder/{branche}:refs/heads/{branche}"], + cwd=args.depot, env=env, timeout=300.0) + if code != 0: + print(f"❌ {branche} — push refusé : {err[:200]}", file=sys.stderr) + echecs += 1 + continue + + code, sortie, err = _run( + ["gh", "pr", "create", "--base", args.base, "--head", branche, + "--title", f"[auto] {titre_pr}", "--body", corps_de_pr(task_id, rapport)], + cwd=args.depot, timeout=120.0) + if code != 0: + print(f"⚠️ {branche} — poussée, mais PR non créée : {err[:200]}", file=sys.stderr) + echecs += 1 + continue + + print(f"✅ {branche} → {sortie.splitlines()[-1] if sortie else 'PR créée'}") + + return 1 if echecs else 0 + + +def main() -> int: + parseur = argparse.ArgumentParser( + description="Publie les branches task/* produites par DreamCoder sur le Deck.") + parseur.add_argument("--hote", default=DECK_HOTE, + help=f"hôte SSH du Deck (défaut : {DECK_HOTE})") + parseur.add_argument("--clone", default=DECK_CLONE, + help="clone de travail DreamCoder sur le Deck") + parseur.add_argument("--prod", default=DECK_PROD, + help="racine du runtime de prod (rapports de tâche)") + parseur.add_argument("--cle", default=CLE_DECK, help="clé SSH du Deck") + parseur.add_argument("--depot", default=".", help="dépôt local depuis lequel pousser") + parseur.add_argument("--remote", default="origin", help="remote GitHub cible") + parseur.add_argument("--base", default="master", help="branche de base des PR") + parseur.add_argument("--dry-run", action="store_true", + help="montre ce qui serait publié, sans rien pousser ni ouvrir") + args = parseur.parse_args() + + if not os.path.exists(args.cle): + print(f"❌ Clé SSH introuvable : {args.cle}", file=sys.stderr) + return 1 + + return recolter(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/seed_models_db.py b/seed_models_db.py index ac14056..bd90a3f 100644 --- a/seed_models_db.py +++ b/seed_models_db.py @@ -24,6 +24,7 @@ from core.models_db import ( get_db_stats, + set_model_routing_tier, update_quota_realtime, upsert_access_channel, upsert_api_key, @@ -44,11 +45,11 @@ def seed_providers(): "id": "local", "name": "LM Studio (Local)", "type": "local", - "api_endpoint": "http://192.168.1.x:1234/v1/chat/completions", + "api_endpoint": "http://192.168.1.10:1234/v1/chat/completions", "auth_method": "local", "confidentiality": "total", "cascade_priority": 1.0, - "notes": "RTX 5070 Ti (16GB VRAM), 9 modèles chargés. Coût zéro, confidentialité totale.", + "notes": "GPU locale (16GB VRAM), 9 modèles chargés. Coût zéro, confidentialité totale.", }, { "id": "gemini_free", @@ -189,7 +190,7 @@ def seed_providers(): "auth_method": "local", "confidentiality": "total", "cascade_priority": 1.1, - "notes": "Ollama local sur RTX 5070 Ti. Inférence ultra-rapide.", + "notes": "Ollama local sur GPU locale. Inférence ultra-rapide.", }, { "id": "anthropic_native", @@ -293,8 +294,9 @@ def seed_models(): {"id": "zai-glm-4.7", "provider_id": "cerebras", "display_name": "Zai GLM 4.7 (Cerebras)", "tier": "paid", "context_input": 8192, "context_output": 4096, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "supports_thinking": 1, "speciality": "raisonnement", "recommended_use": "Raisonnement polyvalent ultra-rapide (Cerebras)", "notes": "Clé Payante (Cerebras)"}, # ═══ OPENROUTER API ═══ - {"id": "meta-llama/llama-3.3-70b-instruct:free", "provider_id": "openrouter", "display_name": "Llama 3.3 70B Instruct (Free via OpenRouter)", "tier": "free", "routing_tier": "moyen", "context_input": 131072, "context_output": 4096, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "raisonnement", "recommended_use": "Raisonnement fort gratuit via OpenRouter", "notes": "Plan Gratuit (OpenRouter)"}, - {"id": "meta-llama/llama-3.2-3b-instruct:free", "provider_id": "openrouter", "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": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "ultra_rapide", "recommended_use": "Tâches rapides gratuites via OpenRouter", "notes": "Plan Gratuit (OpenRouter)"}, + {"id": "openrouter/auto", "provider_id": "openrouter", "display_name": "OpenRouter Auto", "tier": "paid", "routing_tier": "moyen", "context_input": 131072, "context_output": 4096, "cost_input_per_m": 0.15, "cost_output_per_m": 0.60, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "raisonnement", "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é."}, + {"id": "meta-llama/llama-3.3-70b-instruct:free", "provider_id": "openrouter", "display_name": "Llama 3.3 70B Instruct (Free via OpenRouter)", "tier": "free", "context_input": 131072, "context_output": 4096, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "raisonnement", "recommended_use": "Slug :free mort (404 13/08) — hors cascade", "notes": "unavailable for free. Gardé au catalogue, routing_tier retiré."}, + {"id": "meta-llama/llama-3.2-3b-instruct:free", "provider_id": "openrouter", "display_name": "Llama 3.2 3B Instruct (Free via OpenRouter)", "tier": "free", "context_input": 131072, "context_output": 4096, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "ultra_rapide", "recommended_use": "Slug :free mort (404 13/08) — hors cascade", "notes": "unavailable for free. Gardé au catalogue, routing_tier retiré."}, {"id": "anthropic/claude-3.5-sonnet", "provider_id": "openrouter", "display_name": "Claude 3.5 Sonnet (OpenRouter)", "tier": "paid", "routing_tier": "fort", "context_input": 200000, "context_output": 8192, "cost_input_per_m": 3.0, "cost_output_per_m": 15.0, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Raisonnement, code, agentique via OpenRouter", "notes": "Payant"}, {"id": "anthropic/claude-sonnet-5", "provider_id": "openrouter", "display_name": "Claude 5 Sonnet (OpenRouter)", "tier": "paid", "routing_tier": "fort", "context_input": 200000, "context_output": 8192, "cost_input_per_m": 2.0, "cost_output_per_m": 10.0, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Raisonnement, code, agentique via OpenRouter", "notes": "Payant (Nouveau Claude 5)"}, {"id": "openai/gpt-5.2", "provider_id": "openrouter", "display_name": "GPT-5.2 (OpenRouter)", "tier": "fort", "context_input": 400000, "context_output": 128000, "cost_input_per_m": 1.75, "cost_output_per_m": 14.0, "supports_tools": 1, "supports_vision": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "raisonnement", "recommended_use": "Seul accès à la famille OpenAI GPT-5.x du catalogue — agentique/contexte long", "notes": "GPT-5.5 existe aussi sur OpenRouter (plus récent) mais pricing non confirmé au 03/07/2026"}, @@ -321,16 +323,16 @@ def seed_models(): # ═══ ALIBABA DASHSCOPE CODING PLAN LITE (abonnement requêtes) ═══ # Allowlist exacte GET /v1/models (2026-07-26). Coût token = 0 (forfait). # Contextes : doc officielle FAQ Coding Plan. - {"id": "dashscope/qwen3.7-plus", "provider_id": "dashscope", "display_name": "Qwen3.7 Plus (Coding Plan)", "tier": "subscription", "routing_tier": "fort", "context_input": 1000000, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_vision": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Flagship Qwen Coding Plan — vision + thinking, contexte 1M", "notes": "Lite amorti (~¥40/mois). ID API exact: qwen3.7-plus", "last_tested": "2026-07-26"}, - {"id": "dashscope/qwen3.6-plus", "provider_id": "dashscope", "display_name": "Qwen3.6 Plus (Coding Plan)", "tier": "subscription", "routing_tier": "fort", "context_input": 1000000, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_vision": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Qwen Coding Plan — vision + thinking", "notes": "ID API exact: qwen3.6-plus", "last_tested": "2026-07-26"}, - {"id": "dashscope/qwen3.5-plus", "provider_id": "dashscope", "display_name": "Qwen3.5 Plus (Coding Plan)", "tier": "subscription", "routing_tier": "moyen", "context_input": 1000000, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_vision": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Qwen Coding Plan polyvalent — vision", "notes": "ID API exact: qwen3.5-plus", "last_tested": "2026-07-26"}, - {"id": "dashscope/qwen3-max-2026-01-23", "provider_id": "dashscope", "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, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "raisonnement", "recommended_use": "Qwen Max snapshot Coding Plan", "notes": "ID API exact: qwen3-max-2026-01-23", "last_tested": "2026-07-26"}, - {"id": "dashscope/qwen3-coder-next", "provider_id": "dashscope", "display_name": "Qwen3 Coder Next (Coding Plan)", "tier": "subscription", "routing_tier": "moyen", "context_input": 262144, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Défaut moteur Coding Plan — génération de code (pas de thinking)", "notes": "Thinking non supporté. ID API exact: qwen3-coder-next", "last_tested": "2026-07-26"}, - {"id": "dashscope/qwen3-coder-plus", "provider_id": "dashscope", "display_name": "Qwen3 Coder Plus (Coding Plan)", "tier": "subscription", "routing_tier": "fort", "context_input": 1000000, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Code + contexte 1M (pas de thinking)", "notes": "Thinking non supporté. ID API exact: qwen3-coder-plus", "last_tested": "2026-07-26"}, - {"id": "dashscope/kimi-k2.5", "provider_id": "dashscope", "display_name": "Kimi K2.5 (Coding Plan)", "tier": "subscription", "routing_tier": "fort", "context_input": 262144, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_vision": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Moonshot Kimi via Coding Plan — vision + agentique", "notes": "ID API exact: kimi-k2.5", "last_tested": "2026-07-26"}, - {"id": "dashscope/glm-5", "provider_id": "dashscope", "display_name": "GLM-5 (Coding Plan)", "tier": "subscription", "routing_tier": "fort", "context_input": 202752, "context_output": 32768, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "GLM-5 via forfait Alibaba (distinct de Zhipu payant)", "notes": "ID API exact: glm-5 — ne pas confondre avec provider zhipu", "last_tested": "2026-07-26"}, - {"id": "dashscope/glm-4.7", "provider_id": "dashscope", "display_name": "GLM-4.7 (Coding Plan)", "tier": "subscription", "routing_tier": "moyen", "context_input": 202752, "context_output": 32768, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "GLM-4.7 via forfait Alibaba", "notes": "ID API exact: glm-4.7", "last_tested": "2026-07-26"}, - {"id": "dashscope/MiniMax-M2.5", "provider_id": "dashscope", "display_name": "MiniMax M2.5 (Coding Plan)", "tier": "subscription", "routing_tier": "moyen", "context_input": 196608, "context_output": 32768, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "polyvalent", "recommended_use": "MiniMax via Coding Plan (distinct de MINIMAX_API_KEY)", "notes": "ID API exact: MiniMax-M2.5 (casse significative)", "last_tested": "2026-07-26"}, + {"id": "dashscope/qwen3.7-plus", "provider_id": "dashscope", "display_name": "Qwen3.7 Plus (Coding Plan)", "tier": "subscription", "context_input": 1000000, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_vision": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Flagship Qwen Coding Plan — vision + thinking, contexte 1M", "notes": "Lite amorti (~¥40/mois). ID API exact: qwen3.7-plus", "last_tested": "2026-07-26"}, + {"id": "dashscope/qwen3.6-plus", "provider_id": "dashscope", "display_name": "Qwen3.6 Plus (Coding Plan)", "tier": "subscription", "context_input": 1000000, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_vision": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Qwen Coding Plan — vision + thinking", "notes": "ID API exact: qwen3.6-plus", "last_tested": "2026-07-26"}, + {"id": "dashscope/qwen3.5-plus", "provider_id": "dashscope", "display_name": "Qwen3.5 Plus (Coding Plan)", "tier": "subscription", "context_input": 1000000, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_vision": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Qwen Coding Plan polyvalent — vision", "notes": "ID API exact: qwen3.5-plus", "last_tested": "2026-07-26"}, + {"id": "dashscope/qwen3-max-2026-01-23", "provider_id": "dashscope", "display_name": "Qwen3 Max 2026-01-23 (Coding Plan)", "tier": "subscription", "context_input": 262144, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "raisonnement", "recommended_use": "Qwen Max snapshot Coding Plan", "notes": "ID API exact: qwen3-max-2026-01-23", "last_tested": "2026-07-26"}, + {"id": "dashscope/qwen3-coder-next", "provider_id": "dashscope", "display_name": "Qwen3 Coder Next (Coding Plan)", "tier": "subscription", "context_input": 262144, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Défaut moteur Coding Plan — génération de code (pas de thinking)", "notes": "Thinking non supporté. ID API exact: qwen3-coder-next", "last_tested": "2026-07-26"}, + {"id": "dashscope/qwen3-coder-plus", "provider_id": "dashscope", "display_name": "Qwen3 Coder Plus (Coding Plan)", "tier": "subscription", "context_input": 1000000, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Code + contexte 1M (pas de thinking)", "notes": "Thinking non supporté. ID API exact: qwen3-coder-plus", "last_tested": "2026-07-26"}, + {"id": "dashscope/kimi-k2.5", "provider_id": "dashscope", "display_name": "Kimi K2.5 (Coding Plan)", "tier": "subscription", "context_input": 262144, "context_output": 65536, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_vision": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "Moonshot Kimi via Coding Plan — vision + agentique", "notes": "ID API exact: kimi-k2.5", "last_tested": "2026-07-26"}, + {"id": "dashscope/glm-5", "provider_id": "dashscope", "display_name": "GLM-5 (Coding Plan)", "tier": "subscription", "context_input": 202752, "context_output": 32768, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "GLM-5 via forfait Alibaba (distinct de Zhipu payant)", "notes": "ID API exact: glm-5 — ne pas confondre avec provider zhipu", "last_tested": "2026-07-26"}, + {"id": "dashscope/glm-4.7", "provider_id": "dashscope", "display_name": "GLM-4.7 (Coding Plan)", "tier": "subscription", "context_input": 202752, "context_output": 32768, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "code", "recommended_use": "GLM-4.7 via forfait Alibaba", "notes": "ID API exact: glm-4.7", "last_tested": "2026-07-26"}, + {"id": "dashscope/MiniMax-M2.5", "provider_id": "dashscope", "display_name": "MiniMax M2.5 (Coding Plan)", "tier": "subscription", "context_input": 196608, "context_output": 32768, "cost_input_per_m": 0.0, "cost_output_per_m": 0.0, "supports_thinking": 1, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "polyvalent", "recommended_use": "MiniMax via Coding Plan (distinct de MINIMAX_API_KEY)", "notes": "ID API exact: MiniMax-M2.5 (casse significative)", "last_tested": "2026-07-26"}, # ═══ DEEPINFRA (Pay-as-you-go) ═══ {"id": "deepinfra/llama-3.3-70b-instruct", "provider_id": "deepinfra", "display_name": "Llama 3.3 70B Instruct (DeepInfra)", "tier": "paid", "context_input": 131072, "context_output": 4096, "cost_input_per_m": 0.23, "cost_output_per_m": 0.23, "supports_tools": 1, "supports_json_mode": 1, "supports_streaming": 1, "speciality": "raisonnement", "recommended_use": "Routage alternatif Tier Moyen/Fort — excellent rapport qualité/prix"}, @@ -387,6 +389,24 @@ def seed_models(): count += 1 print(f" {'✅' if ok else '❌'} {mid}") + # 13/08 : slugs :free morts — les retirer du routing_tier même si l'upsert + # non-destructif (#T254) a conservé l'ancienne valeur. + for slug_mort in ( + "meta-llama/llama-3.3-70b-instruct:free", + "meta-llama/llama-3.2-3b-instruct:free", + "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", + ): + set_model_routing_tier(slug_mort, None) + return count @@ -571,14 +591,13 @@ def seed_routing_rules(): {"task_type": "confidentiel", "recommended_model": "qwen2.5-14b-instruct-1m", "provider_id": "local", "justification": "100% local, confidentialité totale", "effective_cost": "0.00 $/M"}, {"task_type": "embeddings", "recommended_model": "nomic-embed-text-v1.5", "provider_id": "local", "justification": "Embeddings airgapped", "effective_cost": "0.00 $/M"}, - # ═══ DASHSCOPE CODING PLAN (forfait ~¥40/mois amorti) ═══ - # Forfait Lite : quota 4 requêtes/min et ~600/jour → recommandé pour du code - # interactif à faible débit (pas pour routine_batch / fort débit). - {"task_type": "code_generation", "recommended_model": "dashscope/qwen3-coder-next", "provider_id": "dashscope", "justification": "Défaut Coding Plan : code sans thinking, forfait amorti (~¥40/mois) — quota 4 RPM/600 j, pas pour fort débit", "effective_cost": "0.00 $/M (forfait ~¥40/mois amorti)"}, - {"task_type": "code_complexe", "recommended_model": "dashscope/qwen3-coder-plus", "provider_id": "dashscope", "justification": "Code + contexte 1M (sans thinking), forfait amorti (~¥40/mois) — quota 4 RPM/600 j, pas pour fort débit", "effective_cost": "0.00 $/M (forfait ~¥40/mois amorti)"}, - {"task_type": "code_revision", "recommended_model": "dashscope/qwen3.7-plus", "provider_id": "dashscope", "justification": "Flagship vision + thinking, forfait amorti (~¥40/mois) — quota 4 RPM/600 j, pas pour fort débit", "effective_cost": "0.00 $/M (forfait ~¥40/mois amorti)"}, - {"task_type": "refactoring", "recommended_model": "dashscope/glm-5", "provider_id": "dashscope", "justification": "GLM-5 thinking via forfait Alibaba, forfait amorti (~¥40/mois) — quota 4 RPM/600 j, pas pour fort débit", "effective_cost": "0.00 $/M (forfait ~¥40/mois amorti)"}, - {"task_type": "agentique", "recommended_model": "dashscope/kimi-k2.5", "provider_id": "dashscope", "justification": "Kimi K2.5 vision + agentique, forfait amorti (~¥40/mois) — quota 4 RPM/600 j, pas pour fort débit", "effective_cost": "0.00 $/M (forfait ~¥40/mois amorti)"}, + # D-8 : Dashscope hors recommandations (401 + CGU). Repli sur modèles déjà + # présents dans les règles voisines (code_rapide / pair_programming / planification). + {"task_type": "code_generation", "recommended_model": "gemini-3.5-flash", "provider_id": "gemini_free", "justification": "D-8 : Dashscope désactivé — repli gratuit thinking (même famille que code_rapide)", "effective_cost": "0.00 $/M"}, + {"task_type": "code_complexe", "recommended_model": "claude-sonnet-4-6", "provider_id": "claude_cli", "justification": "D-8 : Dashscope désactivé — repli CLI Sonnet (même famille que pair_programming)", "effective_cost": "0.57 $/M amorti"}, + {"task_type": "code_revision", "recommended_model": "gemini-3.5-flash", "provider_id": "gemini_free", "justification": "D-8 : Dashscope désactivé — repli gratuit thinking", "effective_cost": "0.00 $/M"}, + {"task_type": "refactoring", "recommended_model": "claude-sonnet-4-6", "provider_id": "claude_cli", "justification": "D-8 : Dashscope désactivé — repli CLI Sonnet", "effective_cost": "0.57 $/M amorti"}, + {"task_type": "agentique", "recommended_model": "deepseek-v4-pro", "provider_id": "deepseek", "justification": "D-8 : Dashscope désactivé — repli raisonnement long (même famille que planification)", "effective_cost": "0.435 $/M"}, ] count = 0 @@ -730,6 +749,7 @@ def seed_access_channels(): # OpenRouter API openrouter_models = [ + ("openrouter/auto", "openrouter/auto", 800, 50.0), ("meta-llama/llama-3.3-70b-instruct:free", "meta-llama/llama-3.3-70b-instruct:free", 800, 50.0), ("meta-llama/llama-3.2-3b-instruct:free", "meta-llama/llama-3.2-3b-instruct:free", 500, 80.0), ("anthropic/claude-3.5-sonnet", "anthropic/claude-3.5-sonnet", 800, 60.0), @@ -849,7 +869,7 @@ def seed_access_channels(): model_id, None, access_method="local", provider_alias=model_id, speed_tier="fast", is_default=1, - notes="LM Studio local (RTX 5070 Ti)", + notes="LM Studio local (GPU locale)", ) if ok: count += 1 @@ -863,7 +883,7 @@ def seed_access_channels(): model_id, None, access_method="api_rest", provider_alias=alias, speed_tier="fast", latency_ttft_ms=500, throughput_tps=tps, - is_default=1, notes="Ollama local (RTX 5070 Ti)", + is_default=1, notes="Ollama local (GPU locale)", ) if ok: count += 1 diff --git a/services/approval_resume_service.py b/services/approval_resume_service.py index 6fbf59c..0f8123d 100644 --- a/services/approval_resume_service.py +++ b/services/approval_resume_service.py @@ -12,6 +12,12 @@ 2. La branche Git éphémère de la session suspendue n'est finalisée qu'ICI : fusionnée si la reprise réussit, annulée si le plan est rejeté. Tant que la décision n'est pas prise, elle reste en place — c'est le travail en attente. +3. [#T379] La reprise TERMINE l'exécution : c'est donc ici, et nulle part + ailleurs, que la session doit recevoir son statut terminal et sa date de + fin. Incident du 17/08 : trois DAG rejoués terminés à 20:06 (journal + « rejouée — succès/échec »), et vingt minutes plus tard leurs sessions + portaient encore `status='running'` avec 13 tâches `pending` — rien ne + tournait, et rien ne le disait. """ import logging @@ -30,6 +36,53 @@ async def _nettoyer_mcp(mcp_bridge) -> None: logger.debug(f"[REPRISE] Arrêt MCP ignoré : {e}") +def _cloturer_session( + session_id: str, + succes: bool, + erreur: str | None = None, + task_count: int = 0, + result_summary: str | None = None, +) -> None: + """ + [#T379] Écrit l'état terminal de la session d'une exécution reprise. + + Une exécution qui se termine doit le dire, dans tous les cas : sans cet + appel, la session suspendue reste à jamais sur le statut qui était le + sien au moment de la suspension. C'est exactement le constat du 17/08 : + trois DAG terminés à 20:06, trois sessions encore `running` vingt minutes + plus tard, devenues « zombies » une heure après par ramassage — donc + diagnostiquées comme une panne alors qu'elles s'étaient simplement + terminées sans le dire. + + ARBITRAGE (documenté dans la PR) : toute issue négative — échec de DAG, + rejet du Reviewer, contrat non satisfait, exception, rejet humain — porte + le statut `error`, et la cause se lit dans `error_message`. Les lectures + existantes convergent toutes vers ce vocabulaire fermé : le compteur + d'erreurs de `get_session_stats`, la pastille rouge de l'historique IHM + (`SessionHistory.tsx`), le prédicat `status='running'` du ramasseur de + zombies, la clôture `session_status ≠ "running"` du board DAG (#T305), + le filtre `get_sessions(status_filter='error')`. Un statut distinct + (« failed ») sortirait silencieusement ces sessions du compteur d'erreurs + et de la pastille rouge sans qu'aucune lecture actuelle ne sache les + afficher. La distinction « panne » vs « échec de contenu » est déjà + portée par `error_message` — c'est ainsi que le board sépare aujourd'hui + les 46 zombies et les 22 timeouts des autres erreurs. + + Garde-fous respectés : cette fonction n'est appelée QUE sur un chemin qui + a réellement terminé l'exécution (jamais sur une session encore en cours, + jamais sur une attente d'approbation), et `record_session_end` ne peut + pas faire échouer la reprise (il journalise ses propres erreurs). + """ + from core.session_history import record_session_end + record_session_end( + session_id, + "success" if succes else "error", + task_count=task_count, + error_message=(erreur[:500] if erreur else None), + result_summary=result_summary, + ) + + def _finaliser_git(engine, git_branch: str | None, succes: bool, tasks_status: dict) -> None: """ Finalise la branche éphémère laissée en place par la session suspendue. @@ -78,6 +131,13 @@ async def reprendre_apres_approbation( # en annonçant un succès serait le pire des retours. logger.error(f"[REPRISE] Demande {request_id} sans DAG exploitable — reprise abandonnée.") hitl_store.marquer_reprise(request_id, succes=False) + # [#T379] La reprise s'arrête ici définitivement : l'exécution doit + # porter un statut terminal au lieu de rester suspendue à jamais. + if demande.get("session_id"): + _cloturer_session( + demande["session_id"], succes=False, + erreur="Reprise abandonnée : aucun DAG exploitable dans la demande approuvée.", + ) return {"status": "vide", "request_id": request_id} from core.execution_budget import ExecutionBudget @@ -97,37 +157,54 @@ async def reprendre_apres_approbation( ) config = load_config() - engine, registry, mcp_bridge, _gateway = monter_moteur(session_id, objectif, config) - if on_event_callback: - engine.on_event = on_event_callback - - # [#T253] Restaurer le contrat d'acceptation dans l'historique du moteur. - # `ReviewLoop._criteres_du_plan()` le cherche dans `state.history` — or ce - # moteur vient d'être monté, son historique est VIDE : sans cette - # restauration, le contrat posé par le Planner meurt avec la session - # suspendue et n'est jamais évalué (mesuré en prod le 11/08 : un plan - # approuvé, 2 critères émis, 0 vérifié). On reconstitue le porteur plutôt - # que d'ajouter un chemin parallèle : la reprise doit ressembler au chemin - # normal, pas le contourner. - criteres = demande.get("criteres") or [] - if criteres: - from core.acceptance_contract import CLE_CONTRAT - from core.state import StateUpdate - engine.state.history.append(StateUpdate( - agent_name="planner", - status="success", - result_data="Plan approuvé par l'humain — contrat restauré pour la reprise.", - metadata={CLE_CONTRAT: criteres}, - )) - logger.info( - f"[REPRISE] [T253] Contrat d'acceptation restauré : {len(criteres)} critère(s)." - ) - else: - logger.info("[REPRISE] [T253] Aucun contrat d'acceptation associé à cette demande.") tasks_status: dict = {} has_error = True + erreur_reprise: str | None = None + engine = None + mcp_bridge = None try: + engine, registry, mcp_bridge, _gateway = monter_moteur(session_id, objectif, config) + if on_event_callback: + engine.on_event = on_event_callback + + # [#T253] Restaurer le contrat d'acceptation dans l'historique du moteur. + # `ReviewLoop._criteres_du_plan()` le cherche dans `state.history` — or ce + # moteur vient d'être monté, son historique est VIDE : sans cette + # restauration, le contrat posé par le Planner meurt avec la session + # suspendue et n'est jamais évalué (mesuré en prod le 11/08 : un plan + # approuvé, 2 critères émis, 0 vérifié). On reconstitue le porteur plutôt + # que d'ajouter un chemin parallèle : la reprise doit ressembler au chemin + # normal, pas le contourner. + # + # [Incident du 17/08] Deux précisions : + # - ce StateUpdate est injecté EN TÊTE d'un historique neuf : il devient + # mécaniquement le PLANCHER du contrat (la première entrée lue par + # `ReviewLoop._entrees_contrat()`), que les plans correctifs pourront + # enrichir mais jamais remplacer ni affaiblir ; + # - on y joint le constat de l'état des critères À L'INSTANT DE LA + # REPRISE, donc avant tout travail du DAG rejoué : les critères déjà + # vrais à cet instant seront exclus du verdict comme « satisfaits + # d'avance », exactement comme sur le chemin normal. + criteres = demande.get("criteres") or [] + if criteres: + from core.acceptance_contract import CLE_CONTRAT, CLE_ETAT_INITIAL, constater_etat_initial + from core.state import StateUpdate + etat_initial = constater_etat_initial(criteres) + engine.state.history.append(StateUpdate( + agent_name="planner", + status="success", + result_data="Plan approuvé par l'humain — contrat restauré pour la reprise.", + metadata={CLE_CONTRAT: criteres, CLE_ETAT_INITIAL: etat_initial}, + )) + logger.info( + f"[REPRISE] [T253] Contrat d'acceptation restauré : {len(criteres)} critère(s) " + f"(plancher de la reprise ; {sum(1 for v in etat_initial.values() if v)} déjà satisfait(s) " + "avant le rejeu, exclu(s) du verdict comme preuve)." + ) + else: + logger.info("[REPRISE] [T253] Aucun contrat d'acceptation associé à cette demande.") + await mcp_bridge.start(registry, user_prompt=objectif) budget = ExecutionBudget.from_config(session_id) # [#T253] `executer_dag_et_controles` et NON `execute_dag` : la reprise doit @@ -145,11 +222,52 @@ async def reprendre_apres_approbation( ) except Exception as e: has_error = True + erreur_reprise = f"Exception pendant la reprise : {e}" logger.error(f"[REPRISE] Échec de l'exécution reprise {request_id} : {e}") finally: - await _nettoyer_mcp(mcp_bridge) + if mcp_bridge is not None: + await _nettoyer_mcp(mcp_bridge) - _finaliser_git(engine, demande.get("git_branch"), not has_error, tasks_status) + # [#T379] Une exécution qui se termine doit le dire, dans TOUS les cas : + # succès, échec de DAG, rejet du Reviewer, contrat non satisfait, + # exception. Le chemin normal (run_full_pipeline) appelle déjà + # `record_session_end` sur chacune de ses sorties ; la reprise est l'autre + # chemin qui termine l'exécution, et c'est celui qui ne l'écrivait jamais + # — incident du 17/08 : trois sessions restées `running` alors que leurs + # DAG étaient finis. Le moteur a décidé la cause (`cause_echec`), + # l'exception porte la sienne : on écrit les deux, jamais un simple bool. + # Garde-fou : cette clôture n'intervient qu'APRÈS la fin effective du rejeu + # — une session en attente d'approbation n'arrive jamais ici. + if has_error: + _cloturer_session( + session_id, succes=False, + erreur=getattr(engine, "cause_echec", None) or erreur_reprise + or "Reprise du DAG approuvé terminée en échec.", + task_count=len(tasks_status), + ) + else: + _cloturer_session( + session_id, succes=True, + task_count=len(tasks_status), + result_summary=( + f"Reprise après approbation terminée : " + f"{len(tasks_status)} tâche(s) rejouée(s) avec succès." + ), + ) + + if engine is not None: + _finaliser_git(engine, demande.get("git_branch"), not has_error, tasks_status) + # [#T379] Même signal de fin que `_finalize_session` sur le chemin + # normal : l'IHM branchée sur la reprise doit voir la clôture elle aussi. + if engine.on_event: + try: + await engine.on_event("orchestration_completed", { + "session_id": session_id, + "status": "error" if has_error else "success", + "request_id": request_id, + }) + except Exception as e: + logger.debug(f"[REPRISE] Événement de clôture ignoré : {e}") hitl_store.marquer_reprise(request_id, succes=not has_error) logger.info( @@ -176,6 +294,18 @@ def annuler_apres_rejet(request_id: str) -> dict[str, Any]: if not demande: return {"status": "introuvable", "request_id": request_id} + # [#T379] Le rejet humain est une fin d'exécution définitive : la session + # suspendue doit porter un statut terminal et une date de fin MAINTENANT. + # Sans cela elle reste `waiting_approval` pour toujours — le ramasseur de + # zombies ne regarde que `status='running'`, personne d'autre ne la + # ramassera. Fait avant le nettoyage Git : même si celui-ci échoue, la + # clôture de la session ne doit pas être perdue. + if demande.get("session_id"): + _cloturer_session( + demande["session_id"], succes=False, + erreur="Plan rejeté par l'utilisateur (HITL) — exécution abandonnée.", + ) + git_branch = demande.get("git_branch") if not git_branch: return {"status": "rien_a_annuler", "request_id": request_id} diff --git a/services/datetime_query.py b/services/datetime_query.py new file mode 100644 index 0000000..6484df3 --- /dev/null +++ b/services/datetime_query.py @@ -0,0 +1,74 @@ +""" +services/datetime_query.py — Questions d'heure et date vocales (mode:chat). + +Répond en Zero-LLM aux questions sur l'heure, la date et le jour de la semaine. +Détection déterministe → lecture de l'horloge système → phrase TTS courte. + +Ce module est conçu pour être appelé de manière synchrone car il ne réalise +aucune opération d'E/S bloquante (lecture mémoire uniquement). +""" + +from __future__ import annotations + +import logging +from datetime import datetime +from typing import Optional + +# Configuration du logger pour le suivi des requêtes temporelles +logger = logging.getLogger(__name__) + +# Marqueurs de questions temporelles pour la détection déterministe +_TIME_MARKERS = frozenset({"heure", "quelle heure", "il est quelle heure", "quelle heure est-il"}) +_DATE_MARKERS = frozenset({"date", "on est quel jour", "quel jour on est", "quel jour sommes-nous"}) +_WEEKDAY_MARKERS = frozenset({"quel jour", "jour de la semaine"}) + +def match_datetime_query(prompt: str) -> Optional[str]: + """ + Détecte si la phrase utilisateur est une question temporelle. + Retourne le type de question ('time', 'date', 'weekday') ou None. + """ + norm = prompt.lower().strip() + + # Garde-fou : les questions d'agenda (« à quelle heure je commence demain ? ») + # ne doivent pas être traitées ici pour laisser la main au spécialiste calendrier. + agenda_markers = {"agenda", "calendrier", "travail", "commence", "finis", "rdv", "rendez-vous"} + if any(m in norm for m in agenda_markers): + return None + + if any(m in norm for m in _TIME_MARKERS): + return "time" + if any(m in norm for m in _DATE_MARKERS): + return "date" + if any(m in norm for m in _WEEKDAY_MARKERS): + return "weekday" + + return None + +def resolve_datetime_query(prompt: str, now: Optional[datetime] = None) -> Optional[str]: + """ + Résout la question temporelle en générant une phrase TTS. + + Args: + prompt: La phrase utilisateur normalisée. + now: Instant injectable pour faciliter les tests unitaires. + + Returns: + Une phrase TTS courte ou None si la question n'est pas temporelle. + """ + query_type = match_datetime_query(prompt) + if not query_type: + return None + + # Utilise l'instant fourni ou l'heure système actuelle + dt = now or datetime.now() + + if query_type == "time": + return f"Il est {dt.hour} heures {dt.minute:02d}." + + if query_type == "date": + return f"Nous sommes le {dt.day} {dt.strftime('%B')} {dt.year}." + + if query_type == "weekday": + return f"Nous sommes {dt.strftime('%A')}." + + return None diff --git a/services/execute_service.py b/services/execute_service.py index 662bb60..19ebc68 100644 --- a/services/execute_service.py +++ b/services/execute_service.py @@ -44,12 +44,45 @@ "descend", "descends", }) _VOLET_OFF_MARKERS = frozenset({"descend", "descends", "baisse", "ferme", "fermer"}) -_VOLET_ON_MARKERS = frozenset({"monte", "ouvre", "ouvrir", "leve"}) + +# [#T363] Formes interrogatives NON AMBIGUËS : elles demandent un état et +# n'ordonnent rien, et aucune n'est une transcription plausible d'un impératif. +# « est-elle » / « est-il » sont volontairement ABSENTS : `normalize_vocal_stt` +# les réécrit en « éteins » (`core/vocal_stt_normalize.py:38`) parce que le STT +# confond réellement les deux — les inclure ici ferait perdre de vraies commandes +# mal transcrites. Pour ces formes-là, seul le point d'interrogation tranche. +# « etat de » figure sans point d'interrogation : « État de la lumière de la +# chambre. » est une demande de rapport, jamais un ordre. +_MARQUEURS_INTERROGATIFS = ( + "est-ce", "est ce", "qu'est-ce", "qu est ce", + "etat de", "l'etat", "quel est", "quelle est", + "c'est quoi", "c est quoi", "combien", +) +# Premier mot impératif : l'ordre explicite prime sur la forme interrogative. +_IMPERATIFS_ACTION = frozenset({ + "allume", "eteins", "ouvre", "ferme", "monte", "baisse", "descend", "descends", + "mets", "met", "coupe", "active", "desactive", "demarre", "arrete", "stop", + "regle", "augmente", "diminue", "leve", "remonte", "remontre", "releve", +}) +# Infinitif d'action : « tu peux allumer la lumière ? » reste une commande polie. +_INFINITIFS_ACTION = frozenset({ + "allumer", "eteindre", "ouvrir", "fermer", "mettre", "couper", "activer", + "desactiver", "demarrer", "arreter", "stopper", "regler", "baisser", "monter", + "augmenter", "diminuer", "lever", "remonter", "descendre", +}) +# Formes réellement dites (STT) pour ouvrir le volet. « remontre » est une +# transcription fréquente de « remonte » (faster-whisper FR) ; « relève » était +# reconnu par personne le 17/08. Ajoutées sans toucher à la reconnaissance +# existante (« monte », « ouvre », « ouvrir », « leve »). +_VOLET_ON_MARKERS = frozenset({ + "monte", "ouvre", "ouvrir", "leve", + "remonte", "remontre", "releve", +}) # Écho TTS / phrases d'état — ne pas interpréter comme commande _VOLET_STATUS_MARKERS = frozenset({"sont", "est", "ete", "etait", "etaient", "seront", "deja", "maintenant"}) _VOLET_COVER_ENTITY = "cover.living_room_blind" _VOLET_SCRIPT = "script.blind_action" -_VOLET_MOVING_ENTITY = "input_boolean.volet_serre_mouvement" +_VOLET_MOVING_ENTITY = "input_boolean.blind_moving" _VOLET_STOP_WORDS = frozenset({"stop", "stoppe", "arrete", "arret", "arreter"}) # Table pièce → entité clim (extensible : ajouter une ligne suffit pour une 2e clim). # La détection reste zéro-LLM et choisit l'entité selon la pièce citée, défaut = salon. @@ -93,7 +126,7 @@ _CLIMATE_SPLIT_HVAC_AND_TEMP = True _SALON_LIGHT_GROUP = "light.living_room" _SALON_LIGHT_MEMBERS = ( - "light.sonoff_1001601d46", + "light.hallway", "light.hallway", "light.bedside", ) @@ -190,12 +223,25 @@ def load_ha_commands() -> list[dict[str, Any]]: "salon": ("light.living_room", {"on": "light.turn_on", "off": "light.turn_off"}), "chambre": ("light.bedroom", {"on": "light.turn_on", "off": "light.turn_off"}), "chevet": ("light.bedside", {"on": "light.turn_on", "off": "light.turn_off"}), - "cuisine": ("light.sonoff_1000f18da8", {"on": "light.turn_on", "off": "light.turn_off"}), + "cuisine": ("light.kitchen", {"on": "light.turn_on", "off": "light.turn_off"}), "serre": ("cover.living_room_blind", {"on": "cover.open_cover", "off": "cover.close_cover"}), "volet serre": ("cover.living_room_blind", {"on": "cover.open_cover", "off": "cover.close_cover"}), "clim salon": ("climate.living_room", {"on": "climate.turn_on", "off": "climate.turn_off"}), } +# [T356] Libellé TTS humain pour les entités pilotées connues : la phrase +# « ne répond pas » doit nommer l'appareil pour que l'utilisateur +# agisse sur la bonne cause (« le volet de la serre ne répond pas », pas « je +# n'ai pas pu exécuter la commande »). Repli : friendly_name HA de l'état. +_ENTITE_LIBELLE: dict[str, str] = { + _VOLET_COVER_ENTITY: "le volet de la serre", + "light.living_room": "la lumière du salon", + "light.bedroom": "la lumière de la chambre", + "light.bedside": "la lumière de chevet", + "light.kitchen": "la lumière de la cuisine", + _CLIMATE_DEFAULT_ENTITY: "la climatisation du salon", +} + def match_ha_volet_stop(prompt: str) -> HACommandMatch | None: """Stop volet — « stop », « arrête le volet », etc.""" @@ -215,7 +261,7 @@ def match_ha_volet_stop(prompt: str) -> HACommandMatch | None: def match_ha_volet_keywords(prompt: str) -> HACommandMatch | None: """ - Volet (unique cover volet_serre_rideau — alias vocal « volets du salon »). + Volet (unique cover.living_room_blind — alias vocal « volets du salon »). Prioritaire sur le match lumière salon quand « volet » est présent. """ norm = normalize_ha_command_prompt(prompt) @@ -332,6 +378,48 @@ def match_ha_room_keywords(prompt: str) -> HACommandMatch | None: return None +def _est_question_sans_ordre(prompt: str, norm: str) -> bool: + """ + [#T363] La phrase est-elle une QUESTION qui ne porte aucun ordre ? + + Mesuré le 17/08 : « est-ce que la lumière du salon est allumée ? » produisait + `light.turn_on` sur `light.living_room`, et « État de la lumière de la chambre. » + produisait `light.turn_off` sur `light.bedroom` (fuzzy 0,82 contre « eteins la + lumiere de la chambre »). Interroger la maison l'actionnait — et la réponse + « Lumière de la chambre éteinte. » est indiscernable d'un rapport d'état. + Six formulations de ce type sur 30 jours dans `vocal_audit_log`. + + Deux mécanismes y menaient : le repli de `match_ha_room_keywords` qui force + `is_on = True` sur le seul mot « lumière », et le fuzzy qui rapproche une + question d'une phrase impérative du catalogue. Le garde-fou est posé ici, au + point d'entrée unique, plutôt que dans chacun d'eux. + + L'ordre explicite l'emporte toujours sur la forme interrogative : « tu peux + allumer la lumière ? » et « allume la lumière ? » restent des commandes. Ce + n'est que la question SANS verbe d'action qui rend la main à la cascade, qui + la traitera en lecture d'état. + + ⚠️ Tout se juge sur le prompt BRUT (minuscules, accents retirés), jamais sur + `norm` : `normalize_ha_command_prompt` réécrit « allumée » en « allume » et, + via `normalize_vocal_stt`, « est-elle » en « éteins » + (`core/vocal_stt_normalize.py:38`). Après normalisation, « Est-elle la + lumière de la chambre ? » est littéralement devenue un ordre d'extinction : + juger la forme interrogative sur ce texte-là reviendrait à ne jamais la voir. + """ + brut = _strip_accents(prompt.strip().lower()) + est_question = brut.endswith("?") or any( + marqueur in brut for marqueur in _MARQUEURS_INTERROGATIFS + ) + if not est_question: + return False + mots = re.findall(r"[\w'-]+", brut) + if mots and mots[0] in _IMPERATIFS_ACTION: + return False + if set(mots) & _INFINITIFS_ACTION: + return False + return True + + def match_ha_command(prompt: str) -> HACommandMatch | None: """ Match déterministe ha_commands.json avec tolérance STT (exact puis fuzzy). @@ -340,6 +428,27 @@ def match_ha_command(prompt: str) -> HACommandMatch | None: if not norm: return None + # [#T363] Une question n'est pas un ordre : rendre la main à la cascade, qui + # dispose d'un chemin de lecture d'état. Ne jamais actionner la maison sur un + # doute — le coût d'une lecture manquée est nul, celui d'une lampe allumée à + # 3 h du matin ne l'est pas. + if _est_question_sans_ordre(prompt, norm): + logger.debug("[HA CMD] '%s' : question sans ordre, pas une commande", prompt[:60]) + return None + + # Volet : la détection déterministe prime sur le fuzzy ha_commands. Un + # marker explicite (« remonte », « descend ») est plus fiable que la + # similarité floue — mesuré le 17/08, le fuzzy confondait « remonte le + # volet » avec la phrase stop « arrete le volet » (score 0.867) et produisait + # `stop` au lieu de `open`. L'écho TTS (markers de statut) ou une phrase + # bruitée sans marker retombent ensuite sur le fuzzy, sans régression. + stop = match_ha_volet_stop(prompt) + if stop: + return stop + volet = match_ha_volet_keywords(prompt) + if volet: + return volet + best: HACommandMatch | None = None best_score = 0.0 @@ -376,12 +485,6 @@ def match_ha_command(prompt: str) -> HACommandMatch | None: ) return ensure_volet_via_script(best) - stop = match_ha_volet_stop(prompt) - if stop: - return stop - volet = match_ha_volet_keywords(prompt) - if volet: - return volet climate = match_ha_climate_command(prompt) if climate: return climate @@ -630,6 +733,113 @@ async def read_ha_state(entity_id: str) -> dict[str, Any] | None: return None +# ── [T356] Vérification de l'état AVANT d'exécuter une commande domotique ───── +# +# Le moteur n'inspectait jamais l'état de l'entité visée : il envoyait le +# service, lisait le code HTTP et annonçait le succès. Or HA répond HTTP 200 à +# un appel de service visant une entité `unavailable` : l'appareil n'exécute +# jamais la commande, et le moteur disait « Volet fermé » alors que rien ne +# bougeait. Mesuré en prod le 17/08 : 146 entités `unavailable`/`unknown`, dont +# 17 pilotables (cover.living_room_blind, 6 light.sonoff_*, 7 switch.sonoff_*). +# +# On réutilise `read_ha_state` (déjà présent — pas de troisième chemin). La +# lecture d'état est un GET /api/states/ sur la session keep-alive partagée +# (~10-40 ms LAN vs 271 ms pour la commande) : un aller-retour léger, nécessaire +# car on doit savoir AVANT l'appel si l'entité est morte pour ne pas annoncer un +# succès. Une lecture indisponible (HA muet, erreur réseau) n'est PAS « l'appareil +# est mort » : on exécute quand même, comme avant. + +def _entite_physique_a_verifier(ha_service: str, ha_entity: str) -> str | None: + """ + Détermine l'entité dont il faut lire l'état avant d'exécuter `ha_service`. + + Les commandes de volet passent par `script.blind_action` (entity_id + vide) : l'entité physique à vérifier est la cover `cover.living_room_blind`. + Pour tout autre service, on vérifie l'entité passée en paramètre. Retourne + None si aucune entité n'est identifiable (script générique sans cible) — on + exécute alors sans vérification. + """ + if ha_service == _VOLET_SCRIPT: + return _VOLET_COVER_ENTITY + return ha_entity or None + + +def _cover_sans_retour_de_position(entity_id: str, state: dict) -> bool: + """ + [#T385] Un volet piloté sans capteur de position reste en `unknown` : c'est + son état NORMAL, pas une panne. + + Mesuré sur le vrai HA le 17/08 : `cover.living_room_blind` — le volet + UNIQUE de l'installation, et la cible de la commande vocale la plus + utilisée — est en `unknown` alors qu'il fonctionne (script relancé avec + succès à 13:53). #T356 traitait `unknown` comme `unavailable` : sans ce + garde-fou, chaque commande de volet aurait répondu « le volet de la serre + ne répond pas » pendant que le volet bouge. + + Le critère reste étroit et vérifiable : domaine `cover`, et aucun attribut + `current_position`. Un volet QUI sait rendre sa position et tombe en + `unknown` continue d'être traité comme suspect. `unavailable`, lui, bloque + toujours — c'est l'état par lequel HA dit qu'un appareil est injoignable. + """ + if not entity_id.startswith("cover."): + return False + return "current_position" not in (state.get("attributes") or {}) + + +def _phrase_entite_hors_service(entity_id: str, state: dict) -> str: + """ + Phrase TTS qui nomme l'appareil qui ne répond pas, pour que l'utilisateur + agisse sur la bonne cause (« le volet de la serre ne répond pas », pas « je + n'ai pas pu exécuter la commande »). + """ + libelle = _ENTITE_LIBELLE.get(entity_id) + if not libelle: + friendly = (state.get("attributes") or {}).get("friendly_name") + if friendly: + libelle = str(friendly).lower() + else: + libelle = entity_id + return f"{libelle[0].upper()}{libelle[1:]} ne répond pas." + + +async def _verifier_etat_entite(ha_service: str, ha_entity: str) -> str | None: + """ + Lit l'état de l'entité visée avant exécution. + + Retourne une phrase de blocage nommant l'appareil si l'entité est + `unavailable`/`unknown` (ne pas annoncer un succès). Retourne None si + l'entité est vivante OU si l'état est invérifiable (lecture en échec) : dans + les deux cas la commande peut partir. + + [#T385] Une exception étroite : un `cover` SANS attribut `current_position` + en `unknown` est dans son état normal (volet piloté sans capteur), pas en + panne — voir `_cover_sans_retour_de_position`. + """ + entity_id = _entite_physique_a_verifier(ha_service, ha_entity) + if not entity_id: + return None + state = await read_ha_state(entity_id) + if state is None: + # État invérifiable ≠ appareil mort : ne pas bloquer une commande dont + # on ne connaît pas l'état. Le moteur doit rester utilisable sous le doute. + logger.debug("[T356] Lecture d'état indisponible pour %s — on exécute", entity_id) + return None + etat = (state.get("state") or "").lower() + if etat == "unknown" and _cover_sans_retour_de_position(entity_id, state): + # [#T385] État normal d'un volet sans capteur de position, pas une panne. + logger.debug( + "[T385] %s en 'unknown' sans retour de position — on exécute", entity_id + ) + return None + if etat in ("unavailable", "unknown"): + logger.warning( + "[T356] Entité %s hors service (état=%s) — commande %s non exécutée", + entity_id, etat, ha_service, + ) + return _phrase_entite_hors_service(entity_id, state) + return None + + def _est_action_rejouable(ha_service: str) -> bool: """ [#T323] L'action peut-elle être rejouée sans effet de bord observable ? @@ -640,11 +850,16 @@ def _est_action_rejouable(ha_service: str) -> bool: rejoué la rallume. Liste blanche stricte — tout service inconnu est traité comme non rejouable, parce que le coût d'une double commande physique chez l'utilisateur est bien supérieur à celui d'un échec annoncé. + + Le script volet n'est PLUS rejouable depuis #T350 : il est passé par + `script.turn_on` et son script HA est en `mode: restart`. Rejouer + `turn_on` ANNULE l'exécution lancée par la première tentative (la seconde + commande redémarre le script), donc une reprise interrompt au lieu de + réessayer. On échoue franchement plutôt que de laisser le volet en cours de + course sans suivi. """ if ha_service == _VOLET_SCRIPT: - # Le script volet reçoit une action explicite (open/close/stop) : rejouer - # le même ordre absolu est sans effet de bord. - return True + return False action = ha_service.split(".", 1)[-1] return action in { "turn_on", "turn_off", @@ -677,15 +892,44 @@ async def execute_ha_service( if not ha_token: return False, "Token HA non configuré. Commande non exécutée." + # [T356] Vérifier l'état de l'entité visée AVANT d'envoyer le service : si + # elle est `unavailable`/`unknown`, ne pas annoncer un succès — HA répondrait + # HTTP 200 mais l'appareil n'exécuterait rien. La phrase rendue nomme + # l'appareil qui ne répond pas. Une lecture d'état indisponible n'est pas un + # blocage : la commande part quand même (voir _verifier_etat_entite). Un + # garde-fou : toute erreur inattendue de la VÉRIFICATION ne doit pas bloquer + # la commande — le doute ne doit jamais devenir un refus. + try: + blocage = await _verifier_etat_entite(ha_service, ha_entity) + except Exception as exc: # pragma: no cover - filet de sécurité + logger.warning("[T356] Vérification d'état en échec pour %s : %s — on exécute", ha_entity, exc) + blocage = None + if blocage: + return False, blocage + domain, action = ha_service.split(".", 1) - api_url = f"{ha_url.rstrip('/')}/api/services/{domain}/{action}" + if domain == "script": + # script. → script.turn_on : rend la main immédiatement sans + # attendre la fin du script. L'appel direct + # POST /api/services/script/ ne répond qu'une fois le script + # terminé (ex. volet 26 s) — inutilisable pour une commande vocale + # (timeout 10 s, #T350). Les paramètres du script passent par + # `variables` : à plat, script.turn_on les perdrait et le script + # démarrerait sans branche correspondante. + api_url = f"{ha_url.rstrip('/')}/api/services/script/turn_on" + payload: dict[str, Any] = { + "entity_id": ha_service, # script. + "variables": dict(service_data) if service_data else {}, + } + else: + api_url = f"{ha_url.rstrip('/')}/api/services/{domain}/{action}" + payload: dict[str, Any] = dict(service_data) if service_data else {} + if ha_entity and "entity_id" not in payload: + payload["entity_id"] = ha_entity headers = { "Authorization": f"Bearer {ha_token}", "Content-Type": "application/json", } - payload: dict[str, Any] = dict(service_data) if service_data else {} - if ha_entity and "entity_id" not in payload: - payload["entity_id"] = ha_entity http_session = _get_ha_session() # Compatibilité Daikin : régler le mode AVANT la température, en 2 appels @@ -721,7 +965,9 @@ async def execute_ha_service( # La reprise est réservée aux actions REJOUABLES : rejouer `close_cover` est # sans effet de bord, rejouer `toggle` inverserait deux fois. Toute action # hors de cette liste échoue franchement plutôt que de risquer une double - # commande physique chez l'utilisateur. + # commande physique chez l'utilisateur. Le script volet (désormais via + # `script.turn_on`, `mode: restart`) n'est pas rejouable : une seconde + # commande annulerait la première (#T350). tentatives = 2 if _est_action_rejouable(ha_service) else 1 resp_status = None resp_text = "" diff --git a/services/ha_state_query.py b/services/ha_state_query.py index 19116b3..95b5db8 100644 --- a/services/ha_state_query.py +++ b/services/ha_state_query.py @@ -13,6 +13,7 @@ from __future__ import annotations import logging +import re from dataclasses import dataclass from services.execute_service import ( @@ -39,7 +40,14 @@ "ouvert", "ouverte", "ouverts", "ferme", "fermee", "fermes", "tourne", "marche", "fonctionne", "statut", "etat", }) -_TEMP_MARKERS = frozenset({"temperature", "combien", "degres", "degre"}) +# [#T364] « combien » ne suffit PLUS à faire une question de température. +# Mesuré en prod le 17/08 : « Combien fait 17 fois 24 ? » était répondu « Il fait +# 22 degrés dans le salon. » en 38 ms, et 5 questions sur 5 commençant par +# « combien » (prix d'un billet, habitants de la France, temps de cuisson) +# renvoyaient la même température. Le mot est trop courant en question libre. +# Il ne compte désormais que s'il est ancré par une pièce connue ; les marqueurs +# ci-dessous, eux, sont sans ambiguïté et suffisent seuls. +_TEMP_MARKERS = frozenset({"temperature", "degres", "degre"}) # Infinitifs d'action : « tu peux allumer le salon ? » reste une commande même # formulée en question (1er mot non impératif). On bascule en commande sauf si un # adjectif d'état est aussi présent (« le salon est allumé ? »). @@ -52,20 +60,95 @@ # Pièce → (entité, attribut) pour la température (source = capteur clim connu). _TEMPERATURE_SOURCES: dict[str, tuple[str, str]] = { "salon": ("climate.living_room", "current_temperature"), + # [#T371] La chambre porte un thermomètre bien vivant + # (`sensor.bedroom_temperature`, 24,49 °C relevé sur le HA de + # prod le 18/08) mais n'était déclarée nulle part. Depuis #T364, « quelle + # est la température de la chambre ? » ne renvoyait plus le salon : elle ne + # renvoyait RIEN, et partait au chat, qui n'a pas accès au capteur. Un + # `sensor.*` porte sa valeur dans l'état et non dans un attribut : + # `_format_temperature` retombe sur `state` quand l'attribut est absent. + "chambre": ("sensor.bedroom_temperature", "state"), } _TEMPERATURE_DEFAULT_ROOM = "salon" +# [#T371] Une température « dehors » n'est pas un état domotique : c'est la +# météo, étage SUIVANT de la cascade (`ha_weather_query`, #T367). Mesuré le +# 18/08 sur le code de master : « quelle est la température extérieure ? » +# répondait le capteur du SALON — une valeur exacte, donc indiscernable d'une +# bonne réponse, et l'étage météo n'était jamais atteint. +_MARQUEURS_EXTERIEUR = frozenset({"exterieur", "exterieure", "exterieurs", "dehors"}) + +# [#T371] Lieu explicitement nommé : « la température DE LA buanderie ». Si le +# lieu cité n'est aucune pièce connue, on ne répond pas plutôt que de servir le +# capteur d'une pièce voisine. +_LOCALISATION_RE = re.compile( + r"\b(?:dans|de|du|au|a)\s+(?:(?:la|le|les|l')\s*)?([a-z]{3,})" +) +# Mots que la préposition attrape sans qu'ils désignent un lieu. « maison » est +# volontairement ici : la maison, pour le moteur, c'est la pièce de vie — donc +# le défaut salon reste bon. +_MOTS_NON_LIEUX = frozenset({ + "temperature", "degre", "degres", "combien", "quelle", "quel", "maison", + "moment", "matin", "soir", "nuit", "journee", "demain", "aujourd", "hui", +}) + + +def _localisation_inconnue(norm: str) -> bool: + """Vrai si la phrase nomme un lieu qui n'est aucune pièce connue.""" + lieux = [m for m in _LOCALISATION_RE.findall(norm) if m not in _MOTS_NON_LIEUX] + if not lieux: + return False + return not any(lieu in _PIECES_CONNUES for lieu in lieux) + # Pièce → (entité lumière, libellé TTS). _LIGHT_ENTITIES: dict[str, tuple[str, str]] = { "salon": ("light.living_room", "du salon"), "chambre": ("light.bedroom", "de la chambre"), "chevet": ("light.bedside", "de chevet"), - "cuisine": ("light.sonoff_1000f18da8", "de la cuisine"), + "cuisine": ("light.kitchen", "de la cuisine"), } -_VOLET_STATE_ENTITY = "input_text.volet_serre_etat" +_VOLET_STATE_ENTITY = "input_text.blind_state" _VOLET_COVER_ENTITY = "cover.living_room_blind" +# [#T364] Pièces réellement connues du moteur : celles qui portent une entité. +# Sert d'ancrage à « combien » et empêche de répondre pour une pièce voisine. +_PIECES_CONNUES = frozenset(_LIGHT_ENTITIES) | frozenset(_TEMPERATURE_SOURCES) +# [#T364] Mot de domaine obligatoire pour la branche lumière : le seul nom de +# pièce ne suffit plus. Mesuré : « Quel est l'attempérature du salon ? » (STT +# abîmé, « attempérature » ne matche pas « temperature ») tombait dans la branche +# lumière et répondait « La lumière du salon est allumée. » — une réponse sur un +# tout autre appareil que celui demandé. +_LUMIERE_MARKERS = frozenset({"lumiere", "lumieres", "lampe", "lampes", "eclairage"}) + +# ── [#T358] Questions GÉNÉRALES : elles parlent du monde, pas de l'installation ── +# +# Mesuré en prod le 17/08 (deux fois, 58 ms) : « en une phrase, c'est quoi un +# volet roulant ? » était répondu « Le volet est partiellement ouvert. » — la +# détection se contentait du mot « volet » dans une phrase interrogative, sans +# vérifier que la question porte sur CET appareil-ci. Le mode `chat` porte 98 % +# du trafic vocal réel, donc toute question de culture générale contenant un nom +# d'appareil était détournée vers une lecture d'état. +_MARQUEURS_EXPLICATION = ( + "explique", "expliquer", "explication", + "comment marche", "comment fonctionne", "comment ca marche", + "comment ca fonctionne", "comment fabrique", "comment installer", + "comment on installe", "comment choisir", + "a quoi sert", "a quoi servent", "definition", + "difference entre", "quelle difference", "quelles differences", +) +# Nom d'appareil précédé d'un article INDÉFINI (« un volet », « une ampoule +# LED ») : marque l'objet générique. Une question d'état réelle emploie un +# déterminant défini ou possessif (« le volet », « ma clim »). +_MOTS_APPAREIL = ( + "volet", "volets", "lumiere", "lumieres", "lampe", "lampes", "ampoule", + "ampoules", "clim", "climatisation", "climatiseur", "chauffage", + "temperature", "thermostat", "store", "stores", +) +_RE_APPAREIL_GENERIQUE = re.compile( + r"\b(?:un|une|des|d'un|d'une)\s+(?:\w+\s+)?(?:" + "|".join(_MOTS_APPAREIL) + r")\b" +) + @dataclass(frozen=True) class HAStateQuery: @@ -75,6 +158,29 @@ class HAStateQuery: display: str = "" # libellé pour la phrase (article inclus) +def _est_question_generale(norm: str, word_set: set[str]) -> bool: + """ + [#T358] La question porte-t-elle sur le monde plutôt que sur l'installation ? + + Ordre imposé par le vocabulaire : une demande d'explication l'emporte sur + l'adjectif d'état, car les deux partagent les mêmes verbes — « comment + fonctionne une climatisation ? » contient « fonctionne », qui est aussi le + marqueur d'état de « la clim fonctionne ? ». Sans cette priorité, la moitié + des définitions repasserait en lecture d'état. + + Vient ensuite l'adjectif d'état, qui tranche en faveur de l'installation : + « est-ce qu'une lumière est allumée ? » reste une question d'état malgré + l'article indéfini. En dernier, un nom d'appareil générique (« un volet + roulant ») signe la question de culture générale, qui doit poursuivre la + cascade jusqu'au chat au lieu de déclencher une lecture HA. + """ + if any(marqueur in norm for marqueur in _MARQUEURS_EXPLICATION): + return True + if word_set & _STATE_ADJ: + return False + return bool(_RE_APPAREIL_GENERIQUE.search(norm)) + + def match_ha_state_query(prompt: str) -> HAStateQuery | None: """Détecte une question d'état domotique. None si c'est une commande ou hors-sujet.""" norm = normalize_ha_command_prompt(prompt) @@ -98,6 +204,13 @@ def match_ha_state_query(prompt: str) -> HAStateQuery | None: if not is_question: return None + # [#T358] Question générale (définition, explication, objet générique) : ne + # pas la détourner vers une lecture d'état. On rend None pour que la cascade + # poursuive jusqu'au chat, qui sait répondre « un volet roulant, c'est… ». + if _est_question_generale(norm, word_set): + logger.debug("[HA STATE] '%s' : question générale, pas une lecture d'état", prompt[:60]) + return None + # Priorité : clim → volet → température → lumière. if word_set & _CLIMATE_KEYWORDS: return HAStateQuery("climate", resolve_climate_entity(norm)) @@ -105,16 +218,38 @@ def match_ha_state_query(prompt: str) -> HAStateQuery | None: if "volet" in norm or "volets" in norm: return HAStateQuery("volet", _VOLET_STATE_ENTITY) - if word_set & _TEMP_MARKERS: - room = next((r for r in _TEMPERATURE_SOURCES if r in norm), _TEMPERATURE_DEFAULT_ROOM) + # [#T364] La pièce citée prime toujours sur le défaut : une question sur la + # chambre ne doit jamais être répondue avec le capteur du salon. + piece_citee = next((p for p in _PIECES_CONNUES if p in norm), None) + demande_thermique = bool(word_set & _TEMP_MARKERS) or ( + "combien" in word_set and piece_citee is not None + ) + if demande_thermique: + # [#T371] Extérieur : ce n'est pas une lecture d'état. On rend None pour + # que l'étage météo réponde ; sinon le salon est servi comme + # température du dehors. + if word_set & _MARQUEURS_EXTERIEUR: + logger.debug("[HA STATE] '%s' : température extérieure → météo", norm[:60]) + return None + # [#T371] Lieu nommé mais inconnu (« la buanderie ») : jamais le capteur + # d'une autre pièce. Le défaut salon ne vaut que si AUCUN lieu n'est + # nommé (« il fait combien ? »). + if piece_citee is None and _localisation_inconnue(norm): + logger.debug("[HA STATE] '%s' : lieu inconnu, pas de repli salon", norm[:60]) + return None + room = piece_citee or _TEMPERATURE_DEFAULT_ROOM entity, attr = _TEMPERATURE_SOURCES.get(room, (None, None)) if not entity: - return None # Pas de source fiable pour cette pièce → ne pas inventer. + # Pièce sans capteur connu → ne pas répondre celle d'à côté. + return None return HAStateQuery("temperature", entity, display=f"{attr}|{room}") - for room, (entity, label) in _LIGHT_ENTITIES.items(): - if room in norm: - return HAStateQuery("light", entity, display=label) + # [#T364] La branche lumière exige le mot de domaine ET la pièce. Sans le + # mot, elle servait de fourre-tout à toute question nommant une pièce. + if word_set & _LUMIERE_MARKERS: + for room, (entity, label) in _LIGHT_ENTITIES.items(): + if room in norm: + return HAStateQuery("light", entity, display=label) return None diff --git a/services/ha_weather_query.py b/services/ha_weather_query.py new file mode 100644 index 0000000..6cce1d1 --- /dev/null +++ b/services/ha_weather_query.py @@ -0,0 +1,343 @@ +""" +services/ha_weather_query.py — Météo locale vocale (mode:chat). + +Répond en Zero-LLM aux demandes de météo (« quel temps fait-il ? », « est-ce +qu'il va pleuvoir demain ? », « la météo ») à partir de l'entité météo de la +maison Home Assistant, au lieu de partir au spécialiste web qui n'a pas la +localisation et finit par livrer un chiffre inventé sur un autre continent. + +Source de vérité : l'entité `weather.*` de la maison (Météo-France), lue via la +session aiohttp partagée et les identifiants HA existants — aucun troisième +chemin HTTP, aucun `requests`. + +Anti-hallucination : on ne répond QUE depuis les données HA lues. Si la lecture +échoue, la prévision est absente pour le jour demandé, ou l'entité est +inconnue, on rend None : la cascade repart vers le spécialiste web comme avant. +On ne fabrique jamais de valeur de repli ni de moyenne. +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import unicodedata +from dataclasses import dataclass +from datetime import date, datetime, timedelta +from enum import Enum +from typing import Any + +import aiohttp + +from services.execute_service import ( + _get_ha_session, + _read_ha_credentials, + read_ha_state, +) + +logger = logging.getLogger(__name__) + +# Entité météo de la maison — UNE seule constante, surchargeable par variable +# d'environnement. Si la variable désigne une entité absente, la lecture +# échouera et on rendra None : comportement voulu (pas de source → pas de météo). +_WEATHER_ENTITY = os.environ.get("HA_WEATHER_ENTITY") or "weather.home" + +# Pièces nommées → question de température INTÉRIEURE, déjà traitée par +# `services/ha_state_query.py`. Si le prompt nomme une pièce, on rend None pour +# ne pas voler une question qui marche aujourd'hui. +_INDOOR_ROOMS = frozenset({"salon", "chambre", "cuisine", "chevet"}) + + +class WeatherHorizon(str, Enum): + """Horizon temporel d'une demande météo.""" + + NOW = "now" # maintenant → attributs de l'entité + TODAY = "today" # aujourd'hui → attributs de l'entité + TOMORROW = "tomorrow" # demain → get_forecasts daily + WEEKEND = "weekend" # week-end → get_forecasts daily (sam + dim) + + +@dataclass(frozen=True) +class WeatherQuery: + """Demande météo reconnue et son horizon.""" + + horizon: WeatherHorizon + + +# Traduction des conditions Home Assistant en français (TTS court). +_CONDITION_LABELS: dict[str, str] = { + "sunny": "ensoleillé", + "partlycloudy": "partiellement nuageux", + "cloudy": "nuageux", + "rainy": "pluvieux", + "pouring": "pluie forte", + "lightning": "orageux", + "fog": "brumeux", + "snowy": "neigeux", + "windy": "venteux", + "clear-night": "ciel dégagé", +} + +# Marqueurs de demande météo (formulations réellement observées en prod). +# « quel temps » couvre « quel temps fait-il / il fait / il fera ». +_WEATHER_PHRASES = ( + "quel temps", + "la meteo", + "la météo", + "il va pleuvoir", + "il pleuvra", + "pleuvoir", +) +_WEATHER_WORDS = frozenset({"meteo", "météo", "pluie", "pleut", "pleuvra"}) +# « il fait combien » = température ; « dehors » distingue l'extérieur de la +# question intérieure (« il fait combien dans le salon », pièce → None). +_OUTDOOR_TEMP_PHRASE = "il fait combien" +_OUTDOOR_MARKER = "dehors" +# [#T371] « quelle est la température extérieure ? » n'était reconnue par +# personne : l'étage d'état la captait et répondait le capteur du SALON, et même +# une fois relâchée elle n'atteignait pas la météo, faute de marqueur. Les deux +# orthographes sont listées parce que `_normalize` de ce module CONSERVE les +# accents — c'est la cause racine de #T383, on ne la refait pas. +_OUTDOOR_MARKERS = ("dehors", "exterieur", "extérieur", "exterieure", "extérieure") +_TEMP_WORDS = ("temperature", "température", "degres", "degrés", "il fait combien") + +# Marqueurs d'horizon. +_HORIZON_WEEKEND = ("week-end", "weekend", "ce week end", "ce weekend") +_HORIZON_TOMORROW = "demain" +_HORIZON_NOW = ("maintenant", "tout de suite") +_HORIZON_TODAY = ("aujourd hui", "aujourd'hui") +# Formulations au futur sans « demain » explicite → jour suivant. +_FUTURE_PHRASES = ("il fera", "il va pleuvoir", "il pleuvra", "pleuvra") + + +def _normalize(text: str) -> str: + """Normalise un prompt vocal (minuscules, accents, apostrophes).""" + t = unicodedata.normalize("NFKC", text or "").lower().strip() + t = re.sub(r"[^\w\sàâäéèêëïîôùûüç'-]", " ", t, flags=re.UNICODE) + return re.sub(r"\s+", " ", t).strip() + + +def _looks_like_weather(norm: str) -> bool: + """Vrai si le prompt normalisé est une demande de météo.""" + if any(p in norm for p in _WEATHER_PHRASES): + return True + words = set(norm.split()) + if words & _WEATHER_WORDS: + return True + # Température extérieure : « il fait combien dehors » (pas de pièce nommée). + if _OUTDOOR_TEMP_PHRASE in norm and _OUTDOOR_MARKER in norm: + return True + # [#T371] Toute formulation « température + extérieur » vaut demande météo. + if any(m in norm for m in _OUTDOOR_MARKERS) and any(t in norm for t in _TEMP_WORDS): + return True + return False + + +def _detect_horizon(norm: str) -> WeatherHorizon: + if any(w in norm for w in _HORIZON_WEEKEND): + return WeatherHorizon.WEEKEND + if _HORIZON_TOMORROW in norm: + return WeatherHorizon.TOMORROW + if any(w in norm for w in _HORIZON_NOW): + return WeatherHorizon.NOW + if any(w in norm for w in _HORIZON_TODAY): + return WeatherHorizon.TODAY + # Formulation au futur sans horizon explicite → jour suivant. + if any(f in norm for f in _FUTURE_PHRASES): + return WeatherHorizon.TOMORROW + return WeatherHorizon.NOW + + +def match_weather_query(prompt: str) -> WeatherQuery | None: + """Détecte une demande météo et son horizon. None si ce n'en est pas une.""" + norm = _normalize(prompt) + if not norm: + return None + # Question de température INTÉRIEURE (pièce nommée) → laisse ha_state_query. + if any(room in norm for room in _INDOOR_ROOMS): + return None + if not _looks_like_weather(norm): + return None + return WeatherQuery(horizon=_detect_horizon(norm)) + + +def _round_temp(value: object) -> int | None: + try: + return round(float(value)) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + + +def _translate_condition(condition: object) -> str: + cond = str(condition or "").strip().lower() + return _CONDITION_LABELS.get(cond, cond) + + +def _format_now(state: dict) -> str: + """Phrase « maintenant/aujourd'hui » depuis les attributs de l'entité.""" + condition = _translate_condition(state.get("state")) + attrs = state.get("attributes", {}) or {} + temp = _round_temp(attrs.get("temperature")) + if temp is None: + # Pas de température exploitable → ne pas inventer de valeur. + return "" + phrase = f"Il fait {temp} degrés, {condition}." + apparent = _round_temp(attrs.get("apparent_temperature")) + if apparent is not None and abs(apparent - temp) > 3: + phrase += f" Ressenti {apparent} degrés." + return phrase + + +def _parse_forecast_date(value: object) -> date | None: + """Extrait la date d'un `datetime` HA (ex. '2026-08-17T00:00:00+00:00').""" + text = str(value or "") + try: + return datetime.fromisoformat(text).date() + except ValueError: + match = re.match(r"(\d{4}-\d{2}-\d{2})", text) + if match: + try: + return date.fromisoformat(match.group(1)) + except ValueError: + return None + return None + + +def _format_forecast_day(entry: dict, label: str) -> str: + """Phrase TTS pour une prévision journalière.""" + condition = _translate_condition(entry.get("condition")) + temp = _round_temp(entry.get("temperature")) + templow = _round_temp(entry.get("templow")) + phrase = f"{label}, {condition}" + if temp is not None: + phrase += f", {temp} degrés" + if templow is not None: + phrase += f" au plus bas {templow}" + phrase += "." + precipitation = entry.get("precipitation") + if isinstance(precipitation, (int, float)) and precipitation > 0: + phrase += f" {round(float(precipitation) * 100)}% de risques de précipitations." + return phrase + + +async def _get_daily_forecast() -> list[dict[str, Any]] | None: + """Appelle `weather.get_forecasts` type daily (?return_response). + + POST sur la MÊME session aiohttp partagée et avec les MÊMES identifiants + que `read_ha_state`. None si la lecture échoue (HA muet, service en erreur, + entité absente). + """ + ha_token, ha_url = _read_ha_credentials() + if not ha_token: + return None + url = f"{ha_url.rstrip('/')}/api/services/weather/get_forecasts?return_response" + headers = { + "Authorization": f"Bearer {ha_token}", + "Content-Type": "application/json", + } + payload = {"entity_id": _WEATHER_ENTITY, "type": "daily"} + try: + session = _get_ha_session() + async with session.post( + url, + json=payload, + headers=headers, + timeout=aiohttp.ClientTimeout(total=3), + ) as resp: + if resp.status != 200: + return None + data = await resp.json() + except (aiohttp.ClientError, TimeoutError, json.JSONDecodeError) as exc: + logger.debug("[HA WEATHER] get_forecasts skip : %s", exc) + return None + + service_response = (data or {}).get("service_response") + if not isinstance(service_response, dict): + return None + entity_data = service_response.get(_WEATHER_ENTITY) + if not isinstance(entity_data, dict): + return None + forecast = entity_data.get("forecast") + if not isinstance(forecast, list): + return None + return list(forecast) + + +async def _forecast_for_date(target: date) -> dict[str, Any] | None: + """Prévision journalière correspondant à `target`, ou None si absente.""" + forecast = await _get_daily_forecast() + if not forecast: + return None + for entry in forecast: + entry_date = _parse_forecast_date(entry.get("datetime")) + if entry_date == target: + return dict(entry) + return None + + +async def _resolve_forecast_day( + prompt: str, + label: str, + day_offset: int, +) -> str | None: + """Prévision du jour `today + day_offset` (ex. demain). None si absente.""" + target = date.today() + timedelta(days=day_offset) + entry = await _forecast_for_date(target) + if entry is None: + logger.info( + "[HA WEATHER] Pas de prévision pour %s (%s) — cascade continue", + target, prompt[:60], + ) + return None + return _format_forecast_day(entry, label) + + +async def _resolve_weekend(prompt: str) -> str | None: + """Prévision du prochain week-end (samedi + dimanche), ou None si absente.""" + today = date.today() + # Jours jusqu'au prochain samedi (weekday() : lundi=0 … samedi=5, dimanche=6). + days_until_saturday = (5 - today.weekday()) % 7 + saturday = today + timedelta(days=days_until_saturday) + sunday = saturday + timedelta(days=1) + sat_entry = await _forecast_for_date(saturday) + sun_entry = await _forecast_for_date(sunday) + if sat_entry is None and sun_entry is None: + logger.info( + "[HA WEATHER] Pas de prévision week-end (%s) — cascade continue", + prompt[:60], + ) + return None + parts = [] + if sat_entry is not None: + parts.append(_format_forecast_day(sat_entry, "Samedi")) + if sun_entry is not None: + parts.append(_format_forecast_day(sun_entry, "Dimanche")) + return " ".join(parts) + + +async def resolve_weather_query(prompt: str) -> str | None: + """Résout une demande météo en phrase TTS courte, ou None si non résolue. + + 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 repart alors vers le spécialiste + web comme avant. Aucune valeur de repli n'est fabriquée. + """ + query = match_weather_query(prompt) + if not query: + return None + + if query.horizon in (WeatherHorizon.NOW, WeatherHorizon.TODAY): + state = await read_ha_state(_WEATHER_ENTITY) + if state is None: + logger.info("[HA WEATHER] Lecture %s indisponible — cascade continue", _WEATHER_ENTITY) + return None + return _format_now(state) or None + + if query.horizon == WeatherHorizon.TOMORROW: + return await _resolve_forecast_day(prompt, "Demain", 1) + + if query.horizon == WeatherHorizon.WEEKEND: + return await _resolve_weekend(prompt) + + return None diff --git a/services/pipeline_service.py b/services/pipeline_service.py index e9c1c62..e230881 100644 --- a/services/pipeline_service.py +++ b/services/pipeline_service.py @@ -23,6 +23,7 @@ import asyncio import hashlib import logging +import time from collections.abc import AsyncGenerator from typing import Any @@ -63,7 +64,7 @@ def apply_workload_override(config: dict, tier: str | None = None, model: str | # ══════════════════════════════════════════════════════════════════ FAST_PATH_SYSTEM_PROMPT = ( - "Tu es l'assistant vocal d'Axel sur sa tablette. " + "Tu es l'assistant vocal de la maison. " "Réponds UNIQUEMENT en français, en phrases naturelles (ou un petit tableau texte " "si on te le demande). Chaleureux, concis. " "INTERDIT : JSON, code, balises, appels d'outils, MCP, function_call, " @@ -79,7 +80,7 @@ def apply_workload_override(config: dict, tier: str | None = None, model: str | # si quota Cerebras (30 RPM) ou indispo. FAST_PATH_PROVIDERS = [ "gpt-oss-120b", - "ollama_pc", # Ollama PC (RTX 5070Ti, fine-tune domotique) — local-first ; repli cloud si PC éteint (connect timeout 2s) + "ollama_pc", # Ollama PC (GPU locale, fine-tune domotique) — local-first ; repli cloud si PC éteint (connect timeout 2s) "gemini-3.5-flash-free", "deepseek-chat", "gemini-2.5-flash-free", @@ -195,16 +196,32 @@ async def run_fast_path( from core.vocal_tools import provider_supports_openai_tools, run_vocal_tool_loop + # [#T352] Résolution préalable des candidats en paires (pname, provider) + # disponibles. La sonde d'hôte a besoin du provider pour lire son base_url, + # et de connaître le nombre de candidats restants pour ne JAMAIS vider la + # cascade : si elle écarterait tous les candidats, le dernier est tenté quand + # même — mieux vaut payer 2 s que ne rien répondre. + candidats_resolus: list[tuple[str, object]] = [] for entry in candidates: if isinstance(entry, tuple): - pname, fast_provider = entry - else: - pname = entry - try: - fast_provider = gateway.get_provider(pname) - except ValueError: - logger.debug(f"[FAST_PATH] Provider {pname} non disponible, skip") - continue + candidats_resolus.append(entry) + continue + try: + candidats_resolus.append((entry, gateway.get_provider(entry))) + except ValueError: + logger.debug(f"[FAST_PATH] Provider {entry} non disponible, skip") + + for index, (pname, fast_provider) in enumerate(candidats_resolus): + # [#T352] Sonde d'accessibilité d'hôte : avant de tenter un provider dont + # l'URL pointe vers un hôte du réseau local, on vérifie que la machine + # décroche et on passe au suivant si ce n'est pas le cas. Jamais sur le + # dernier candidat (ne pas vider la cascade), jamais sur un hôte cloud + # (latence variable, DNS, proxys), et toute exception de la sonde = « on + # ne sait pas » = on tente le provider comme avant. + if index < len(candidats_resolus) - 1 and await _fast_path_hote_muet( + gateway, pname, fast_provider + ): + continue try: # Mini allowlist outils (HA/calendrier/web/mémoire) si provider OpenAI-compat @@ -255,6 +272,37 @@ async def run_fast_path( return response_text +async def _fast_path_hote_muet(gateway, pname: str, fast_provider) -> bool: + """True si l'hôte local du provider ne décroche pas → on le saute (#T352). + + Garde-fous : + - ne sonde QUE les hôtes locaux, via le critère `_est_hote_local` du gateway + (loopback + IP privée LAN ; tout nom DNS est traité comme externe) — un + provider cloud n'est JAMAIS écarté par une sonde TCP ; + - toute exception de la sonde (socket, DNS, boucle asyncio) = « on ne sait + pas » = on tente le provider comme avant (le doute ne devient pas un refus). + """ + try: + from core.llm import provider_health + + base_url = provider_health.base_url_provider(fast_provider) + if not base_url: + return False + hote_port = provider_health.hote_et_port(base_url) + if hote_port is None: + return False + hote, port = hote_port + if not gateway._est_hote_local(hote): + return False # hôte cloud : jamais sondé + if await provider_health.hote_joignable(base_url): + return False # la machine décroche → on tente + logger.info(f"[FAST_PATH] {pname} ignoré : {hote}:{port} ne décroche pas") + return True + except Exception as _e: + logger.debug(f"[FAST_PATH] Sonde hôte ignorée pour {pname} : {_e}") + return False + + async def _persist_fast_path_async(sid: str, prompt: str, result: str, source: str = "") -> None: """Tâche async différée : persiste la session fast-path en BDD + EventStore .""" try: @@ -871,7 +919,12 @@ async def _cleanup_mcp(mcp_bridge) -> None: # Service : Exécution moteur en background (fire-and-forget) # ══════════════════════════════════════════════════════════════════ -async def run_engine_background(objective: str, on_event_callback, session_id: str | None = None) -> None: +async def run_engine_background( + objective: str, + on_event_callback, + session_id: str | None = None, + execution_key: str | None = None, +) -> None: """ Lance le moteur en arrière-plan pour /api/run (fire-and-forget). Met à jour execution_state via AppState pendant l'exécution. @@ -880,6 +933,8 @@ async def run_engine_background(objective: str, on_event_callback, session_id: s objective : Objectif de la tâche on_event_callback: Coroutine async appelée à chaque événement moteur session_id : ID de session optionnel pré-généré (V12 B3-Fix) + execution_key : Clé de concurrence #T324 (entrée du registre à + libérer en fin d'exécution, même sur exception). """ from core.app_state import get_app_state from core.llm_gateway import ( @@ -919,15 +974,26 @@ async def run_engine_background(objective: str, on_event_callback, session_id: s timeout_seconds=duree_session_fond_s(), ) - 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") + final_error = None except Exception as e: logger.error(f"[ENGINE_BG] Erreur : {e}") - async with state.execution_lock: - state.execution_state["status"] = "error" - state.execution_state["error_message"] = str(e) + final_status = "error" + final_engine_state = None + final_error = str(e) + finally: + # [#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. + if execution_key: + await state.end_execution( + execution_key, + status=final_status, + engine_state=final_engine_state, + error_message=final_error, + ) # ══════════════════════════════════════════════════════════════════ @@ -1033,10 +1099,28 @@ async def stream_discussion_fast_path_sse( tier_override: str | None = None, model_override: str | None = None, conversation_id: str | None = None, + enable_vocal_tools: bool = True, ) -> AsyncGenerator[str, None]: """ Générateur SSE pour le mode Discussion vocal (mode=chat). Émet token / sentence / done — jamais Planner/DAG. + + [#T346] Événements supplémentaires (additifs, un client qui les ignore + fonctionne comme avant) : + - thinking : {"type": "thinking", "text": str} — fragment du + raisonnement du modèle, JAMAIS mélangé aux tokens ni au TTS ; + - thinking_done : {"type": "thinking_done", "durationMs": int} — émis à la + fin de la phase de raisonnement (premier token de réponse ou clôture) ; + - tool_call : {"type": "tool_call", "id": str, "toolName": str, + "args": dict} — annonce d'un appel d'outil (statut « running ») ; + - tool_result : {"type": "tool_result", "id": str, "toolName": str, + "status": "success"|"error", "resultData": str|None, + "errorMessage": str|None, "executionTimeMs": int} — résultat de l'outil. + + `enable_vocal_tools` : les outils (HA/calendrier/web/mémoire) ne sont + proposés QU'aux providers qui les supportent + (`provider_supports_openai_tools`) ; pour les autres, aucun aller-retour + n'est ajouté — le flux reste strictement celui d'avant. """ import json @@ -1045,6 +1129,7 @@ async def stream_discussion_fast_path_sse( register_vocal_stream, unregister_vocal_stream, ) + from core.vocal_tools import provider_supports_openai_tools, run_vocal_tool_loop from core.vocal_tts_cache import sanitize_discussion_tts register_vocal_stream(session_id, conversation_id=conversation_id) @@ -1072,26 +1157,115 @@ async def stream_discussion_fast_path_sse( accumulated = "" sentence_buf = "" streamed = False - + # [#T346] Suivi de la phase de raisonnement : None = pas de pensée en + # cours ; sinon l'instant (monotonic) du premier fragment reçu. + debut_pensee: float | None = None + + # [#T352] Résolution préalable des candidats en paires (pname, provider), + # comme dans `run_fast_path` : la sonde d'hôte a besoin du provider pour + # lire son base_url, et de connaître le nombre de candidats restants pour + # ne JAMAIS vider la cascade (le dernier est tenté même s'il est muet). + candidats_resolus: list[tuple[str, object]] = [] for entry in _fast_path_provider_candidates(gateway, tier_override, model_override): + if isinstance(entry, tuple): + # Override de tier : déjà une paire (nom, provider) prête à l'emploi. + candidats_resolus.append(entry) + continue + try: + candidats_resolus.append((entry, gateway.get_provider(entry))) + except ValueError: + logger.debug(f"[STREAM_DISCUSSION] Provider {entry} indisponible, skip") + + for index, (pname, fast_provider) in enumerate(candidats_resolus): if is_vocal_aborted(session_id, conversation_id=conversation_id): yield f"data: {json.dumps({'type': 'aborted'}, ensure_ascii=False)}\n\n" return - if isinstance(entry, tuple): - pname, fast_provider = entry - stream_source = fast_provider.generate_stream( - system_prompt, user_prompt, session_id=session_id + # [#T352] Sonde d'accessibilité d'hôte : même garde-fous que sur le + # chemin non streamé. Jamais sur le dernier candidat (ne pas vider la + # cascade), jamais sur un hôte cloud, et toute exception de la sonde = + # « on ne sait pas » = on tente le provider comme avant. + if index < len(candidats_resolus) - 1 and await _fast_path_hote_muet( + gateway, pname, fast_provider + ): + continue + + # [#T346] Outils vocaux sur le chemin streamé : on RÉUTILISE la + # boucle existante `run_vocal_tool_loop` (pas de seconde boucle + # d'outils). Ses événements de cycle de vie passent par une queue + # au fil de l'eau ; le sentinel None marque la fin de la boucle. + # Un provider sans support d'outils ne paie AUCUN surcoût : la + # vérification est purement locale, zéro aller-retour. + if enable_vocal_tools and provider_supports_openai_tools(fast_provider): + queue_evenements: asyncio.Queue = asyncio.Queue() + + # Paramètre par défaut : la clôture est liée à la queue de CE + # tour de cascade (B023 — variable réassignée à chaque tour). + async def _pousser_evenement_outil(evenement, _queue=queue_evenements): + await _queue.put(evenement) + + tache_outils = asyncio.create_task(run_vocal_tool_loop( + fast_provider, + system_prompt=system_prompt, + user_prompt=user_prompt, + session_id=session_id, + on_event=_pousser_evenement_outil, + )) + tache_outils.add_done_callback( + lambda _t, _queue=queue_evenements: _queue.put_nowait(None) ) - else: - pname = entry + + texte_outils = None try: - gateway.get_provider(pname) - except ValueError: - logger.debug(f"[STREAM_DISCUSSION] Provider {pname} indisponible, skip") + while True: + # Un abort vocal doit couper la boucle d'outils comme + # il coupe le flux de tokens (barge-in). + if is_vocal_aborted(session_id, conversation_id=conversation_id): + tache_outils.cancel() + yield f"data: {json.dumps({'type': 'aborted'}, ensure_ascii=False)}\n\n" + return + try: + evenement = await asyncio.wait_for( + queue_evenements.get(), timeout=0.25 + ) + except TimeoutError: + continue + if evenement is None: + break + yield f"data: {json.dumps(evenement, ensure_ascii=False)}\n\n" + texte_outils = await tache_outils + except Exception as outils_err: + logger.warning( + f"[STREAM_DISCUSSION] {pname} boucle outils échouée : {outils_err}" + ) continue - stream_source = gateway.stream( - pname, system_prompt, user_prompt, session_id=session_id - ) + + if texte_outils: + logger.info(f"[STREAM_DISCUSSION] Réponse outils → {pname}") + # Le résultat des outils est synthétisé par le modèle : il + # passe par token + sentence comme le reste du flux, mais + # JAMAIS par le cache TTL (états HA changeants — même règle + # que le chemin non streamé `run_fast_path`). + final = sanitize_discussion_tts(texte_outils) + if not final: + final = sanitize_discussion_tts("Désolé, peux-tu reformuler ?") + phrases, reste = _pop_complete_sentences(final) + if reste.strip(): + phrases.append(reste.strip()) + for phrase in phrases: + yield f"data: {json.dumps({'type': 'token', 'text': phrase}, ensure_ascii=False)}\n\n" + yield f"data: {json.dumps({'type': 'sentence', 'text': phrase}, ensure_ascii=False)}\n\n" + asyncio.create_task( + _persist_fast_path_async(session_id, user_prompt, final) + ) + yield f"data: {json.dumps({'type': 'done', 'response': final, 'agents_used': ['discussion_chat']}, ensure_ascii=False)}\n\n" + return + # Réponse vide de la boucle d'outils : on retombe sur le stream + # direct du même provider (comme run_fast_path retombe sur + # generate() quand la boucle rend None). + + stream_source = fast_provider.generate_stream( + system_prompt, user_prompt, session_id=session_id + ) try: logger.info(f"[STREAM_DISCUSSION] Streaming → {pname}") @@ -1099,8 +1273,22 @@ async def stream_discussion_fast_path_sse( if is_vocal_aborted(session_id, conversation_id=conversation_id): yield f"data: {json.dumps({'type': 'aborted'}, ensure_ascii=False)}\n\n" return + # [#T346] Canal raisonnement : émis en événement SSE distinct + # (contrat ThinkingBlock). Il n'alimente NI `accumulated` ni + # `sentence_buf` : le raisonnement ne doit JAMAIS partir au + # TTS ni se retrouver dans la réponse rendue ou mise en cache. + raisonnement = chunk.get("reasoning") or "" + if raisonnement: + if debut_pensee is None: + debut_pensee = time.monotonic() + yield f"data: {json.dumps({'type': 'thinking', 'text': raisonnement}, ensure_ascii=False)}\n\n" token = chunk.get("token") or "" if token: + if debut_pensee is not None: + # La réponse commence : la phase de pensée se referme. + duree_ms = int((time.monotonic() - debut_pensee) * 1000) + debut_pensee = None + yield f"data: {json.dumps({'type': 'thinking_done', 'durationMs': duree_ms}, ensure_ascii=False)}\n\n" accumulated += token sentence_buf += token streamed = True @@ -1111,6 +1299,12 @@ async def stream_discussion_fast_path_sse( if clean: yield f"data: {json.dumps({'type': 'sentence', 'text': clean}, ensure_ascii=False)}\n\n" if chunk.get("done"): + if debut_pensee is not None: + # Pensée refermée sans texte de réponse (modèle qui + # n'a rien rendu d'autre) : on notifie quand même. + duree_ms = int((time.monotonic() - debut_pensee) * 1000) + debut_pensee = None + yield f"data: {json.dumps({'type': 'thinking_done', 'durationMs': duree_ms}, ensure_ascii=False)}\n\n" break if streamed: fast_path_cache[_cache_key] = accumulated @@ -1121,6 +1315,7 @@ async def stream_discussion_fast_path_sse( accumulated = "" sentence_buf = "" streamed = False + debut_pensee = None continue if not streamed: diff --git a/static/js/components/model-tooltip.js b/static/js/components/model-tooltip.js index a5533a4..2497777 100644 --- a/static/js/components/model-tooltip.js +++ b/static/js/components/model-tooltip.js @@ -452,14 +452,14 @@ const MODEL_CATALOG = { "RULER": { score: 57.5, unit: "%", rank: "⚠️", desc: "Efficacité de contexte réel (moyenne 50-65%)" } }, perf: { ttft: "<0.08s", throughput: "35–55 tok/s", contextWindow: 1000000, effectiveContext: 550000 }, - pricing: { type: "local", inputPerM: 0, outputPerM: 0, amortizedPerM: 0, currency: "USD", note: "Coût électricité uniquement. RTX 5070 Ti." }, + pricing: { type: "local", inputPerM: 0, outputPerM: 0, amortizedPerM: 0, currency: "USD", note: "Coût électricité uniquement. GPU locale." }, strengths: ["gratuit", "confidentiel 100%", "airgapped", "latence réseau 0"], weaknesses: ["attention effondrée >550k (RULER)", "raisonnement limité", "VRAM limitée"], bestFor: "Résumés de contexte, extraction JSON/Regex, RAG domotique <500k tokens", hallucination_risk: "élevé sur code complexe", confidential: true, - desc: "Inférence locale sur RTX 5070 Ti. 100% gratuit et confidentiel.", - diff: "0.00$/token. RULER 50-65% ⚠️. Airgapped. RTX 5070 Ti + 64 Go RAM.", + desc: "Inférence locale sur GPU locale. 100% gratuit et confidentiel.", + diff: "0.00$/token. RULER 50-65% ⚠️. Airgapped. GPU locale + 64 Go RAM.", usage: "Résumés de contexte, extraction de données, RAG domotique (<500k tokens).", cognitiveProfile: "Confidentialité absolue et latence prévisible. Ses capacités s'effondrent sur les problèmes logiques abstraits hors de ses données d'entraînement. Attention: Lost in the Middle au-delà de 500k tokens." }, diff --git a/static/js/tabs/catalog.js b/static/js/tabs/catalog.js index 22a5cde..a3e92fc 100644 --- a/static/js/tabs/catalog.js +++ b/static/js/tabs/catalog.js @@ -59,7 +59,7 @@ function renderChannelCarousel() { if (!container) return; const channelData = [ - { key: 'local', models: ['local'], quota: 'Illimité (VRAM)', extra: 'Airgapped · RTX 5070 Ti' }, + { key: 'local', models: ['local'], quota: 'Illimité (VRAM)', extra: 'Airgapped · GPU locale' }, { key: 'free-api', models: getModelsForChannel('free-api'), quota: 'Flash: 15 RPM · 1500 RPD\nPro: 2 RPM · 50 RPD', extra: '⚠️ Données entraînement' }, { key: 'cli-gemini-adv', models: getModelsForChannel('cli-gemini-adv'), quota: '~4M tokens/h\n~100M tokens/mois', extra: '🔒 Confidentiel' }, { key: 'cli-claude-pro', models: getModelsForChannel('cli-claude-pro'), quota: '~1.5M tokens/h\n~35M tokens/mois', extra: '🔒 Confidentiel' }, diff --git a/static/js/tabs/swarm.js b/static/js/tabs/swarm.js index d7a3f2a..1aa4283 100644 --- a/static/js/tabs/swarm.js +++ b/static/js/tabs/swarm.js @@ -41,7 +41,7 @@ function renderSwarm(container) {
- +
diff --git a/tests/conftest.py b/tests/conftest.py index d43b1dc..65e7cc3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -25,6 +25,52 @@ from core.state import GlobalState, TaskPayload +# ────────────────────────────────────────────────────────────────── +# Isolation de la base runtime pour TOUS les tests (#T348) +# ────────────────────────────────────────────────────────────────── +# +# Avant #T348, seul `core/event_store.py` déviait : il calculait son chemin par +# défaut indépendamment de `core.runtime_db`, si bien qu'un test qui appelait +# `override_db_path()` croyait être isolé pendant que l'EventStore continuait +# d'écrire dans la vraie base du dépôt (moteur_runtime.db). Le singleton +# `get_event_store()` figeait en plus l'instance — et donc le chemin — au +# premier appel du process. +# +# Cette fixture autouse réoriente la base runtime vers un fichier temporaire +# POUR CHAQUE test, de sorte que rien (EventStore compris) n'écrive jamais dans +# la base du dépôt. Compatible avec la vingtaine de tests qui appellent déjà +# `override_db_path()` et restaurent « l'ancien chemin » en sortie (motif +# `ancien = get_db_path()` … `override_db_path(ancien)`) : comme cette fixture +# s'exécute AVANT les fixtures du test, l'`ancien` qu'ils capturent est le +# chemin temporaire ici posé — jamais celui du dépôt. À la sortie du test, la +# fixture restaure le vrai chemin du dépôt. + + +@pytest.fixture(autouse=True) +def _isoler_base_runtime(tmp_path): + """Récrit `core.runtime_db` vers une base temporaire isolée par test.""" + from core import runtime_db + + ancien = runtime_db.get_db_path() + # Garder le basename `moteur_runtime.db` : test_runtime_db.test_db_path + # vérifie ce suffixe (le dossier tmp_path assure déjà l'isolation). + chemin_iso = str(tmp_path / "moteur_runtime.db") + runtime_db.override_db_path(chemin_iso) + try: + yield + finally: + # Fermer le singleton EventStore AVANT de rendre le chemin / de laisser + # pytest supprimer tmp_path : sinon la connexion WAL reste ouverte sur + # les fichiers -wal/-shm et le cleanup échoue sous Windows (Bugbot #T348). + try: + from core import event_store as _es + if _es._event_store_instance is not None: + _es._event_store_instance.close() + _es._event_store_instance = None + except Exception: + pass + runtime_db.override_db_path(ancien) + @pytest.fixture def auth_headers(): diff --git a/tests/run_benchmark.py b/tests/run_benchmark.py index e39339f..c60ab48 100644 --- a/tests/run_benchmark.py +++ b/tests/run_benchmark.py @@ -12,7 +12,7 @@ {"name": "Local Windows Native", "url": "http://localhost:8001/v1/chat/completions"}, { "name": "Remote endpoint (optional)", - "url": os.environ.get("MOTEUR_BENCHMARK_REMOTE_URL", "http://${DECK_HOST:-192.168.1.x}:8002/v1/chat/completions"), + "url": os.environ.get("MOTEUR_BENCHMARK_REMOTE_URL", "http://${DECK_HOST:-192.168.1.10}:8002/v1/chat/completions"), }, ] API_KEY = os.environ.get("MOTEUR_API_KEY", "changeme") diff --git a/tests/test_directml_inference.py b/tests/test_directml_inference.py index 6eb5609..f41a976 100644 --- a/tests/test_directml_inference.py +++ b/tests/test_directml_inference.py @@ -19,7 +19,7 @@ def main(): exit(1) print("[+] DirectML (DmlExecutionProvider) est disponible !") - print("[+] GPU cible détecte : NVIDIA RTX 5070 Ti (via Direct3D 12 API)") + print("[+] GPU cible détecte : NVIDIA GPU locale (via Direct3D 12 API)") print("[+] L'acceleration locale DirectML est prete a etre utilisee par vos modeles d'IA !") print("[+] Test de validation GPU DirectML termine avec SUCCES !") diff --git a/tests/test_failover.py b/tests/test_failover.py index cd13b21..d2e172f 100644 --- a/tests/test_failover.py +++ b/tests/test_failover.py @@ -51,7 +51,7 @@ async def _simulate_ping(returncode: int) -> bool: with patch("asyncio.create_subprocess_exec", return_value=mock_proc): proc = await asyncio.create_subprocess_exec( - "ping", "-c", "1", "-W", "2", "192.168.1.x", + "ping", "-c", "1", "-W", "2", "192.168.1.10", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, ) @@ -186,10 +186,10 @@ async def mock_scp_upload(local_path: Path, remote_path: str) -> bool: client = paramiko.SSHClient() with patch.object(paramiko.SSHClient, "connect", side_effect=paramiko.ssh_exception.NoValidConnectionsError( - {("192.168.1.x", 22): Exception("Refusé")} + {("192.168.1.10", 22): Exception("Refusé")} )): - client.connect("192.168.1.x", port=22, username="deck", - password="popydeck", timeout=5) + client.connect("192.168.1.10", port=22, username="deck", + password="remote-host", timeout=5) return True except Exception: return False @@ -207,7 +207,7 @@ def enqueue(src: Path, dst: str): queue_file.write_text(json.dumps(queue, indent=2)) # Simuler un cycle sync qui échoue - remote_path = "/e/AuxFilsDesIdees/moteur_agents/moteur_runtime_deck_backup.db" + remote_path = "/opt/vromvrom-engine/moteur_runtime_backup.db" success = await mock_scp_upload(fake_db, remote_path) if not success: enqueue(fake_db, remote_path) @@ -241,7 +241,7 @@ async def test_failover_manager_processes_mqtt_events(): msg_activated.payload = json.dumps({ "status": "ACTIVATED", "timestamp": ts, - "deck_ip": "192.168.1.x", + "deck_ip": "192.168.1.10", }).encode("utf-8") mgr._on_message(None, None, msg_activated) diff --git a/tests/test_memory_db_v9.py b/tests/test_memory_db_v9.py index 496f12d..b3e08f4 100644 --- a/tests/test_memory_db_v9.py +++ b/tests/test_memory_db_v9.py @@ -127,7 +127,7 @@ def test_graph_rag_linkage(self, temp_memory_db): # 2. Créer une entité dans le graphe temp_memory_db.upsert_graph_entity( - name="switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active", + name="switch.example_wake_word", entity_type="HA_Entity", observations=["Vrai commutateur du wake word Tab5 V2"] ) @@ -135,13 +135,13 @@ def test_graph_rag_linkage(self, temp_memory_db): # 3. Créer la liaison ok = temp_memory_db.link_fact_to_entity( fact_id=fact_id, - entity_name="switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active" + entity_name="switch.example_wake_word" ) assert ok is True # 4. Rechercher les faits connectés à l'entité facts = temp_memory_db.get_connected_facts_for_entity( - entity_name="switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active" + entity_name="switch.example_wake_word" ) assert len(facts) == 1 assert facts[0]["id"] == fact_id @@ -150,7 +150,7 @@ def test_graph_rag_linkage(self, temp_memory_db): # 5. Rechercher les entités connectées au fait entities = temp_memory_db.get_connected_entities_for_fact(fact_id=fact_id) assert len(entities) == 1 - assert entities[0]["name"] == "switch.m5stack_tab5_home_assistant_hmi_tab5_wake_word_active" + assert entities[0]["name"] == "switch.example_wake_word" @pytest.mark.asyncio async def test_async_graph_rag_linkage(self, temp_memory_db): @@ -165,7 +165,7 @@ async def test_async_graph_rag_linkage(self, temp_memory_db): # 2. Créer l'entité graphe await temp_memory_db.upsert_graph_entity_async( - name="media_player.m5stack_tab5_home_assistant_hmi_tab5_media_player", + name="media_player.tablet", entity_type="HA_Entity", observations=["Vrai lecteur audio du Tab5 V2"] ) @@ -173,13 +173,13 @@ async def test_async_graph_rag_linkage(self, temp_memory_db): # 3. Créer la liaison asynchrone ok = await temp_memory_db.link_fact_to_entity_async( fact_id=fact_id, - entity_name="media_player.m5stack_tab5_home_assistant_hmi_tab5_media_player" + entity_name="media_player.tablet" ) assert ok is True # 4. Récupérer les faits asynchronement facts = await temp_memory_db.get_connected_facts_for_entity_async( - entity_name="media_player.m5stack_tab5_home_assistant_hmi_tab5_media_player" + entity_name="media_player.tablet" ) assert len(facts) == 1 assert facts[0]["id"] == fact_id diff --git a/tests/test_phase3_hmi.py b/tests/test_phase3_hmi.py index 2f763af..075c872 100644 --- a/tests/test_phase3_hmi.py +++ b/tests/test_phase3_hmi.py @@ -230,6 +230,45 @@ def test_put_prompt_agent_non_editable_refuse(self, client, isolated_config): res = client.put("/api/agents/antigravity_agent", json={"system_prompt": "x"}) assert res.status_code == 422 + def _creer_agent_custom_restreint(self, client, name="agent_restreint_t345"): + """Crée un agent custom restreint à read_file puis le relit depuis config.""" + res = client.post("/api/agents", json={ + "name": name, "label": "Restreint", "tier": "leger", + "allowed_tools": ["read_file"], + }) + assert res.status_code == 200 + return name + + def test_put_allowed_tools_null_leve_la_restriction(self, client, isolated_config): + """#T345 — PATCH avec allowed_tools: null explicite → tous les outils (null).""" + name = self._creer_agent_custom_restreint(client) + res = client.put(f"/api/agents/{name}", json={"allowed_tools": None}) + assert res.status_code == 200 + assert res.json()["allowed_tools"] is None + saved = json.loads(isolated_config.read_text(encoding="utf-8")) + entry = next(e for e in saved["custom_agents"] if e["name"] == name) + assert entry["allowed_tools"] is None + + def test_put_sans_allowed_tools_ne_touche_pas_la_restriction(self, client, isolated_config): + """#T345 — PATCH sans le champ → la restriction existante est conservée.""" + name = self._creer_agent_custom_restreint(client) + res = client.put(f"/api/agents/{name}", json={"label": "Nouveau nom"}) + assert res.status_code == 200 + assert res.json()["allowed_tools"] == ["read_file"] + saved = json.loads(isolated_config.read_text(encoding="utf-8")) + entry = next(e for e in saved["custom_agents"] if e["name"] == name) + assert entry["allowed_tools"] == ["read_file"] + + def test_put_allowed_tools_liste_vide_aucun_outil(self, client, isolated_config): + """#T345 — PATCH avec allowed_tools: [] → aucun outil, et surtout pas null.""" + name = self._creer_agent_custom_restreint(client) + res = client.put(f"/api/agents/{name}", json={"allowed_tools": []}) + assert res.status_code == 200 + assert res.json()["allowed_tools"] == [] + saved = json.loads(isolated_config.read_text(encoding="utf-8")) + entry = next(e for e in saved["custom_agents"] if e["name"] == name) + assert entry["allowed_tools"] == [] + # ────────────────────────────────────────────────────────────────── # #T158 — registre modèles enrichi + toggle/routing-tier diff --git a/tests/test_pricing.py b/tests/test_pricing.py index 1d1d7b6..0d27963 100644 --- a/tests/test_pricing.py +++ b/tests/test_pricing.py @@ -37,15 +37,17 @@ def now(cls, tz=None): def test_free_tier_is_zero(): """gemini-2.5-flash est en Free Tier dans le JSON → coût nul (source unique).""" - assert get_model_pricing("gemini-2.5-flash") == {"input": 0.0, "output": 0.0} + # [#T347] get_model_pricing expose aussi "cache" (ici 0, comme input/output). + assert get_model_pricing("gemini-2.5-flash") == {"input": 0.0, "output": 0.0, "cache": 0.0} def test_cli_and_local_and_subscription_are_free(): """Conventions de gratuité : -cli, local, et modèles d'abonnement.""" - assert get_model_pricing("gemini-3.5-flash-high-cli") == {"input": 0.0, "output": 0.0} - assert get_model_pricing("local") == {"input": 0.0, "output": 0.0} + free = {"input": 0.0, "output": 0.0, "cache": 0.0} + assert get_model_pricing("gemini-3.5-flash-high-cli") == free + assert get_model_pricing("local") == free # claude-opus-4-8 figure dans subscriptions[].models → coût marginal nul - assert get_model_pricing("claude-opus-4-8") == {"input": 0.0, "output": 0.0} + assert get_model_pricing("claude-opus-4-8") == free def test_eur_rates_converted_to_usd(): @@ -59,11 +61,14 @@ def test_eur_rates_converted_to_usd(): def test_alias_fallback_when_absent_from_strategy(): """L'alias générique 'gemini' (absent du JSON) retombe sur FALLBACK_PRICING.""" p = get_model_pricing("gemini") - assert p == pricing.FALLBACK_PRICING["gemini"] + expected = dict(pricing.FALLBACK_PRICING["gemini"]) + # [#T347] sans tarif cache explicite, cache = input (rétrocompat). + expected.setdefault("cache", expected["input"]) + assert p == expected def test_unknown_model_is_zero(): - assert get_model_pricing("modele-bidon-9000") == {"input": 0.0, "output": 0.0} + assert get_model_pricing("modele-bidon-9000") == {"input": 0.0, "output": 0.0, "cache": 0.0} def test_token_tracker_delegates_to_unified_pricing(): diff --git a/tests/test_rag_improvements.py b/tests/test_rag_improvements.py index c755c98..43a4efc 100644 --- a/tests/test_rag_improvements.py +++ b/tests/test_rag_improvements.py @@ -74,7 +74,7 @@ def test_chunk_contextual_prefix(self): store = EmbeddingStore.__new__(EmbeddingStore) content = "# Mon Titre\n\nCeci est un contenu de test suffisamment long pour passer le seuil de 20 caractères et être retenu comme chunk valide." - filepath = os.path.join("e:", "AuxFilsDesIdees", "contexte_ia", "02_Hardware", "rules_esphome.md") + filepath = os.path.join("docs", "ARCHITECTURE.md") sections = store._chunk_markdown(content, filepath) diff --git a/tests/unit/test_ajean_declaration.py b/tests/unit/test_ajean_declaration.py new file mode 100644 index 0000000..e813c87 --- /dev/null +++ b/tests/unit/test_ajean_declaration.py @@ -0,0 +1,121 @@ +""" +tests/unit/test_ajean_declaration.py — Déclaration des providers AJEAN (llama.cpp). + +AJEAN = llama.cpp (llama-server, build b10451) sur le PC local, API OpenAI-compatible +sur le port 8080. Deux entrées symétriques : `ajean_pc` (192.168.1.10, joignable depuis +le Deck) et `ajean_deck` (127.0.0.1, instance Deck à venir). + +Ce que cette PR verrouille (purement déclaratif, AUCUN appel réseau) : +1. Les deux entrées existent dans `OPENAI_COMPAT_PROVIDERS`, avec une `base_url` + complète se terminant par `/v1/chat/completions`. +2. **Point central** : les deux hôtes sont reconnus LOCAUX par `LLMGateway._est_hote_local` + — c'est la preuve qu'on a déclaré des IP privées/loopback, jamais un nom DNS (un nom + d'hôte serait traité comme externe/cloud et perdrait le bénéfice du mode + `privacy_level=local_only`, #T337). +3. Le gateway sait construire les deux providers sans lever, et leur timeout de + connexion est court (≤ 3 s) : un hôte local éteint doit échouer vite. +4. Non-régression : `ollama_local` et `ollama_pc` restent déclarés et constructibles — + cette PR ne débranche rien. +""" +import os +import sys + +# Ajout du répertoire parent au PYTHONPATH (patron des autres tests unitaires). +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core.llm_gateway import LLMGateway # noqa: E402 +from core.openai_compat_provider import ( # noqa: E402 + AJEAN_DEFAULT_MODEL, + OPENAI_COMPAT_PROVIDERS, +) + +# ── 1. Déclaration dans le registre centralisé ─────────────────────────────── + +def test_ajean_pc_declare_dans_le_registre(): + """ajean_pc existe et a une base_url complète jusqu'à /v1/chat/completions.""" + assert "ajean_pc" in OPENAI_COMPAT_PROVIDERS + config = OPENAI_COMPAT_PROVIDERS["ajean_pc"] + assert config["base_url"].endswith("/v1/chat/completions") + # Hôte déclaré par IP LAN, jamais par nom DNS. + assert "192.168.1.10" in config["base_url"] + assert config["default_model"] == AJEAN_DEFAULT_MODEL + + +def test_ajean_deck_declare_dans_le_registre(): + """ajean_deck existe et a une base_url complète jusqu'à /v1/chat/completions.""" + assert "ajean_deck" in OPENAI_COMPAT_PROVIDERS + config = OPENAI_COMPAT_PROVIDERS["ajean_deck"] + assert config["base_url"].endswith("/v1/chat/completions") + # Hôte déclaré en loopback, jamais par nom DNS. + assert "127.0.0.1" in config["base_url"] + assert config["default_model"] == AJEAN_DEFAULT_MODEL + + +def test_modele_ajean_en_constante_unique(): + """Le modèle AJEAN est écrit en constante, pas dispersé en dur dans le registre.""" + assert AJEAN_DEFAULT_MODEL == "Qwen2.5-14B-Instruct-1M-Q4_K_M" + for pid in ("ajean_pc", "ajean_deck"): + assert OPENAI_COMPAT_PROVIDERS[pid]["default_model"] == AJEAN_DEFAULT_MODEL + + +# ── 2. Point central : hôtes reconnus LOCAUX ───────────────────────────────── + +def test_ajes_hotes_reconnus_locaux_par_est_hote_local(): + """Les deux hôtes AJEAN sont LOCAUX pour `_est_hote_local` (IP privée/loopback). + + C'est la preuve qu'on a déclaré des IP, pas un nom DNS : `_est_hote_local` classe + toute IP privée RFC 1918 ou loopback comme locale (#T337), alors qu'un nom d'hôte + (ex. `ajean.local`) serait traité comme externe/cloud et échapperait à la sonde + d'accessibilité. + """ + assert LLMGateway._est_hote_local("192.168.1.10") is True + assert LLMGateway._est_hote_local("127.0.0.1") is True + + +def test_ajean_providers_classes_locaux_via_base_url(): + """Les providers AJEAN du gateway appartiennent bien à la famille locale.""" + gateway = LLMGateway() + assert gateway._is_local_provider("ajean_pc") is True + assert gateway._is_local_provider("ajean_deck") is True + + +# ── 3. Constructibilité + timeout court ────────────────────────────────────── + +def test_gateway_construit_les_providers_ajean(): + """Le gateway instancie ajean_pc et ajean_deck sans lever.""" + gateway = LLMGateway() + for pid in ("ajean_pc", "ajean_deck"): + assert pid in gateway.providers, f"provider '{pid}' absent du gateway" + provider = gateway.providers[pid] + assert provider.base_url.endswith("/v1/chat/completions") + assert provider.model == AJEAN_DEFAULT_MODEL + + +def test_timeout_de_connexion_court_pour_ajean(): + """Timeout de connexion ≤ 3 s : un hôte local éteint doit échouer vite. + + Retrouvé via le tuple `(connect, read)` porté par `provider.timeout`. + """ + gateway = LLMGateway() + for pid in ("ajean_pc", "ajean_deck"): + timeout = gateway.providers[pid].timeout + assert isinstance(timeout, (tuple, list)), f"{pid} : timeout non-tuple" + connect, _read = timeout[0], timeout[1] + assert connect <= 3, f"{pid} : connect timeout {connect}s > 3s (hôte éteint trop lent)" + + +# ── 4. Non-régression : Ollama intact ──────────────────────────────────────── + +def test_ollama_toujours_declare_dans_le_registre(): + """ollama_local et ollama_pc restent dans le registre (#T354, ne rien débrancher).""" + assert "ollama_local" in OPENAI_COMPAT_PROVIDERS + assert "ollama_pc" in OPENAI_COMPAT_PROVIDERS + + +def test_ollama_toujours_constructibles(): + """ollama_local et ollama_pc restent constructibles par le gateway.""" + gateway = LLMGateway() + assert "ollama_local" in gateway.providers + assert "ollama_pc" in gateway.providers + for pid in ("ollama_local", "ollama_pc"): + assert gateway.providers[pid].base_url.endswith("/v1/chat/completions") diff --git a/tests/unit/test_billing_balance_t349.py b/tests/unit/test_billing_balance_t349.py new file mode 100644 index 0000000..5cf8f35 --- /dev/null +++ b/tests/unit/test_billing_balance_t349.py @@ -0,0 +1,208 @@ +""" +tests/unit/test_billing_balance_t349.py — historisation du solde réel des fournisseurs. + +Le collecteur récupère déjà le solde DeepSeek à chaque rafraîchissement mais le +jette après usage immédiat : rien ne part en historique, alors que la table +`billing_history` existe. Ce lot branche l'écriture d'un relevé de solde +(`metric='balance_usd'`, `currency='USD'`, `sync_source='api'`) avec une +politique d'écriture dédupliquante, et une lecture par fournisseur/fenêtre. + +Garde-fous exercés ici : + - un solde `None` (fournisseur injoignable) n'écrit AUCUNE ligne, et le + rafraîchissement se termine normalement sans exception ; + - deux relevés successifs à solde différent → deux lignes, lisibles par + `get_balance_history`, dans l'ordre chronologique, avec la bonne valeur ; + - deux relevés rapprochés à solde INCHANGÉ → une seule ligne ; un relevé + simulé plus d'une heure après → une ligne de plus (temps piloté par + monkeypatch du timestamp, jamais par `sleep`) ; + - une erreur d'écriture en base n'interrompt pas `refresh_all_quotas` : elle + est journalisée et remontée dans `result["errors"]`. + +Aucun réseau : `_collect_deepseek_balance` est mocké. Aucune écriture dans les +bases partagées du poste : `runtime_db.override_db_path()` + `tmp_path`. +""" +import logging +import sqlite3 +import threading +import time + +import pytest + +import core.models_db as models_db +import core.quota_collector as qc +import core.runtime_db as runtime_db +from core import session_history + + +@pytest.fixture(autouse=True) +def _bases_iso(tmp_path, monkeypatch): + """Isolation stricte : catalogue + runtime dans tmp_path, pas de DeepSeek.""" + monkeypatch.setattr(models_db, "_DB_PATH", str(tmp_path / "models_registry.db")) + monkeypatch.setattr(models_db, "_thread_local", threading.local()) + monkeypatch.setattr(runtime_db, "_DB_PATH", str(tmp_path / "moteur_runtime.db")) + monkeypatch.delenv("DEEPSEEK_API_KEY", raising=False) + # Initialise le schéma canonique réel (dont billing_history) dans la base tmp. + runtime_db.get_connection().close() + + +def _seed_catalogue(): + """Catalogue minimal (même seed que test_quota_collector.py) pour que + refresh_all_quotas puisse écrire sans violer les FK du registre.""" + models_db.upsert_provider("gemini_free", name="Gemini Free", type="free_tier") + models_db.upsert_provider("gemini_paid", name="Gemini Paid", type="pay_as_you_go") + models_db.upsert_provider("anthropic_native", name="Anthropic", type="pay_as_you_go") + for i in (1, 2, 3, 4, 5, 6): + key_id = "GEMINI_API_KEY" if i == 1 else f"GEMINI_API_KEY_{i}" + models_db.upsert_api_key( + key_id, provider_id="gemini_free", env_var=key_id, + project_name=f"projet-{i}", key_type="free", + quota_rpm=15, quota_rpd=500, quota_tpm=250000, + ) + models_db.upsert_api_key( + "GEMINI_PAYANT_API_KEY", provider_id="gemini_paid", env_var="GEMINI_PAYANT_API_KEY", + project_name="payant", key_type="paid", + quota_rpm=500, quota_rpd=10000, quota_tpm=4000000, + ) + models_db.upsert_api_key( + "ANTHROPIC_API_KEY", provider_id="anthropic_native", env_var="ANTHROPIC_API_KEY", + project_name="anthropic", key_type="paid", + ) + models_db.upsert_provider("deepseek", name="DeepSeek", type="pay_as_you_go") + models_db.upsert_provider("cloud_apis", name="Cloud APIs", type="unknown") + models_db.upsert_provider("xai", name="xAI", type="pay_as_you_go") + models_db.upsert_api_key( + "DEEPSEEK_API_KEY", provider_id="deepseek", env_var="DEEPSEEK_API_KEY", + project_name="deepseek", key_type="paid", quota_rpm=60, quota_tpm=1000000, + ) + models_db.upsert_api_key( + "CLOUD_API_KEY", provider_id="cloud_apis", env_var="CLOUD_API_KEY", + project_name="cloud", key_type="paid", + ) + models_db.upsert_api_key( + "XAI_API_KEY", provider_id="xai", env_var="XAI_API_KEY", + project_name="grok", key_type="paid", quota_rpm=60, quota_tpm=1000000, + ) + models_db.upsert_subscription( + "claude_pro", name="Claude Pro", rolling_window_hours=5, + hourly_token_limit=1500000, monthly_token_limit=35000000, + ) + models_db.upsert_subscription( + "gemini_advanced", name="Gemini Advanced", rolling_window_hours=5, + hourly_token_limit=4000000, monthly_token_limit=100000000, + ) + + +def _lignes_balance(provider: str = "deepseek") -> list[tuple]: + """Lit les lignes balance_usd de billing_history (timestamp, value) triées asc.""" + conn = sqlite3.connect(runtime_db.get_db_path()) + try: + return conn.execute( + "SELECT timestamp, value FROM billing_history " + "WHERE provider = ? AND metric = 'balance_usd' ORDER BY timestamp ASC", + (provider,), + ).fetchall() + finally: + conn.close() + + +# ────────────────────────────────────────────────────────────────── +# Critère 1 : un solde None n'écrit AUCUNE ligne +# ────────────────────────────────────────────────────────────────── + +def test_solde_none_n_ecrit_rien(): + """[#T349] Un solde None (fournisseur injoignable) n'écrit aucune ligne.""" + assert session_history.insert_balance_snapshot("deepseek", None) == 0 + assert _lignes_balance() == [] + + +def test_refresh_solde_none_se_termine_sans_exception_ni_ligne(monkeypatch): + """[#T349] refresh_all_quotas avec solde None se termine normalement, sans + exception, et n'écrit aucune ligne dans billing_history.""" + _seed_catalogue() + monkeypatch.setattr(qc, "_collect_deepseek_balance", lambda: None) + + resultat = qc.refresh_all_quotas(include_claude=False) + + assert resultat["errors"] == [] # pas d'erreur, le refresh s'est terminé + assert _lignes_balance() == [] # aucun relevé écrit pour un solde None + + +# ────────────────────────────────────────────────────────────────── +# Critère 2 : deux solde différents → deux lignes lisibles et ordonnées +# ────────────────────────────────────────────────────────────────── + +def test_deux_releves_solde_different_deux_lignes(): + """[#T349] Deux relevés successifs à solde différent → deux lignes, lisibles + par get_balance_history, dans l'ordre chronologique, avec la bonne valeur.""" + assert session_history.insert_balance_snapshot("deepseek", 11.89) == 1 + assert session_history.insert_balance_snapshot("deepseek", 8.0) == 1 + + releves = session_history.get_balance_history(hours=24, provider="deepseek") + + assert len(releves) == 2 + assert [r["value"] for r in releves] == [11.89, 8.0] + assert releves[0]["timestamp"] <= releves[1]["timestamp"] # ordre chronologique + assert all(r["metric"] == "balance_usd" for r in releves) + assert all(r["currency"] == "USD" for r in releves) + assert all(r["sync_source"] == "api" for r in releves) + + +# ────────────────────────────────────────────────────────────────── +# Critère 3 : solde inchangé → pas de doublon ; > 1 h → une ligne de plus +# ────────────────────────────────────────────────────────────────── + +def test_solde_inchange_pas_de_doublon_puis_une_ligne_apres_une_heure(): + """[#T349] Deux relevés rapprochés à solde inchangé → une seule ligne ; un + troisième relevé simulé plus d'une heure après → une ligne de plus. + Le temps est piloté en vieillissant le dernier relevé en base (jamais sleep).""" + assert session_history.insert_balance_snapshot("deepseek", 10.0) == 1 + # Relevé immédiat à solde inchangé : pas de doublon (< 1 h, même valeur) + assert session_history.insert_balance_snapshot("deepseek", 10.0) == 0 + assert len(_lignes_balance()) == 1 + + # Simule un relevé plus d'une heure après : on vieillit la ligne existante. + now = time.time() + conn = sqlite3.connect(runtime_db.get_db_path()) + conn.execute( + "UPDATE billing_history SET timestamp = ? WHERE provider = 'deepseek' " + "AND metric = 'balance_usd'", + (now - 3601,), + ) + conn.commit() + conn.close() + + # Solde inchangé MAIS dernier relevé > 1 h → on écrit une nouvelle ligne. + assert session_history.insert_balance_snapshot("deepseek", 10.0) == 1 + assert len(_lignes_balance()) == 2 + + +# ────────────────────────────────────────────────────────────────── +# Critère 4 : une erreur d'écriture n'interrompt pas refresh_all_quotas +# ────────────────────────────────────────────────────────────────── + +def test_erreur_ecriture_n_interrompt_pas_refresh(monkeypatch, caplog): + """[#T349] Une erreur d'écriture en base ne fait pas tomber refresh_all_quotas : + elle est journalisée et remontée dans result['errors']. + + On fait échouer `_get_connection` (pas un monkeypatch de toute la fonction) : + ainsi le try/except de quota_collector voit une vraie exception SQLite, pas + un return 0 silencieux (Bugbot #T349). + """ + _seed_catalogue() + monkeypatch.setattr(qc, "_collect_deepseek_balance", lambda: 11.89) + + def _panne(): + raise sqlite3.OperationalError("base verrouillée (simulé)") + + monkeypatch.setattr("core.session_history._get_connection", _panne) + caplog.set_level(logging.WARNING) + + resultat = qc.refresh_all_quotas(include_claude=False) + + # Le refresh s'est terminé normalement : d'autres clés ont été mises à jour. + assert resultat["updated_keys"] >= 1 + # L'erreur d'écriture est remontée dans errors et journalisée. + assert any("balance_history" in e for e in resultat["errors"]) + assert any("Erreur relevé solde" in r.getMessage() for r in caplog.records) + # Dedup skip ≠ panne : rows reste à 0 après échec (pas de faux positif). + assert resultat.get("balance_snapshot_rows", 0) == 0 diff --git a/tests/unit/test_builtin_providers_etape2.py b/tests/unit/test_builtin_providers_etape2.py index 797622e..155dc4e 100644 --- a/tests/unit/test_builtin_providers_etape2.py +++ b/tests/unit/test_builtin_providers_etape2.py @@ -65,8 +65,8 @@ def _seed_data(): def test_nombre_de_plugins_et_ids_providers_uniques(): ids = [p.descriptor.id for p in BUILTIN_PROVIDER_PLUGINS] - assert len(ids) == len(set(ids)) == 17 - # Les 16 convertis + le pilote de l'étape 1. + assert len(ids) == len(set(ids)) == 18 + # Les 16 convertis + le pilote de l'étape 1 + AJEAN (declaration #AJEAN). assert "mistral" in ids diff --git a/tests/unit/test_bulk_status_libere_sonde.py b/tests/unit/test_bulk_status_libere_sonde.py new file mode 100644 index 0000000..174ee46 --- /dev/null +++ b/tests/unit/test_bulk_status_libere_sonde.py @@ -0,0 +1,41 @@ +from unittest.mock import MagicMock, patch + +from api.routes.context import bulk_set_models_status + + +def test_bulk_status_libere_sonde(): + # get_model / set_model_status sont importés dans la fonction, + # pas au niveau du module : patcher core.models_db. + with patch("core.models_db.get_model") as mock_get, patch( + "core.models_db.set_model_status" + ) as mock_set: + mock_get.return_value = { + "id": "m1", + "status": "inactive", + "desactive_par_sonde": 1, + } + mock_set.return_value = True + + body = MagicMock() + body.ids = ["m1"] + body.status = "inactive" + + result = bulk_set_models_status(body) + + # Même statut : on appelle quand même set_model_status pour + # relâcher la revendication de la sonde (#T380). + assert mock_set.called + assert "m1" in result["modifies"] + assert "m1" not in result["inchanges"] + + +def test_bulk_status_introuvable(): + with patch("core.models_db.get_model") as mock_get: + mock_get.return_value = None + + body = MagicMock() + body.ids = ["unknown"] + body.status = "active" + + result = bulk_set_models_status(body) + assert "unknown" in result["introuvables"] diff --git a/tests/unit/test_call_api_port_tls_t329.py b/tests/unit/test_call_api_port_tls_t329.py index c956f65..a577133 100644 --- a/tests/unit/test_call_api_port_tls_t329.py +++ b/tests/unit/test_call_api_port_tls_t329.py @@ -4,7 +4,7 @@ Mesuré en PRODUCTION le 12/08 (session `chat_683ea8b2cd`) — « Quelle est la température actuelle dans le salon ? » : - Appel API distant: GET http://192.168.1.x:8123/api/states + Appel API distant: GET http://192.168.1.10:8123/api/states [ha_agent] Erreur d'outil de call_api : Erreur de réseau HTTP: ('Connection aborted.', RemoteDisconnected('Remote end closed connection without response')) @@ -16,8 +16,8 @@ nomme pas sa cause. Vérifié en prod, même hôte, même port : - http://192.168.1.x:8123/api/ → ConnectionError / RemoteDisconnected - https://192.168.1.x:8123/api/ → HTTP 401 (le serveur répond) + http://192.168.1.10:8123/api/ → ConnectionError / RemoteDisconnected + https://192.168.1.10:8123/api/ → HTTP 401 (le serveur répond) ⚠️ Ce n'est donc PAS la cause de #T323 (connexion keep-alive réutilisée) : ici `requests.request()` ouvre une session neuve à chaque appel. Deux symptômes @@ -40,7 +40,7 @@ def _env_ha_neutre(monkeypatch): Isole HASS_URL / HA_URL — sans quoi ces tests dépendent de l'ordre de la suite. Régression réelle : ces deux tests passaient isolément et ÉCHOUAIENT en suite - complète. Dès qu'un test antérieur laisse `HASS_URL` pointer 192.168.1.x, + complète. Dès qu'un test antérieur laisse `HASS_URL` pointer 192.168.1.10, `_est_hote_ha()` devient vrai, le rejeu part dans `ha_requests_session()` — que ces tests ne mockent pas — et tente une VRAIE connexion, bloquée par le garde-fou réseau du conftest. Les tests qui veulent l'hôte HA le posent eux- @@ -77,11 +77,11 @@ def _fake_request(method, url, **kwargs): monkeypatch.setattr(requests, "request", _fake_request) - sortie = api.call_api("http://192.168.1.x:8123/api/states") + sortie = api.call_api("http://192.168.1.10:8123/api/states") assert appels == [ - "http://192.168.1.x:8123/api/states", - "https://192.168.1.x:8123/api/states", + "http://192.168.1.10:8123/api/states", + "https://192.168.1.10:8123/api/states", ] assert "22.4" in sortie assert "Erreur" not in sortie @@ -129,7 +129,7 @@ def _fake_request(method, url, **kwargs): monkeypatch.setattr(requests, "request", _fake_request) - api.call_api("https://192.168.1.x:8123/api/states") + api.call_api("https://192.168.1.10:8123/api/states") assert len(appels) == 1 @@ -145,18 +145,18 @@ def _fake_request(method, url, **kwargs): monkeypatch.setattr(requests, "request", _fake_request) - sortie = api.call_api("http://192.168.1.x:8123/api/states?filter=temp#frag") + sortie = api.call_api("http://192.168.1.10:8123/api/states?filter=temp#frag") - assert "https://192.168.1.x:8123/api/states?filter=temp#frag" in sortie + assert "https://192.168.1.10:8123/api/states?filter=temp#frag" in sortie assert "TLS" in sortie def test_politique_tls_du_projet_pour_l_hote_ha(monkeypatch): """L'hôte HA (certificat auto-signé) suit HA_VERIFY_TLS ; les autres non.""" - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HA_VERIFY_TLS", "false") - assert api._verify_pour("https://192.168.1.x:8123/api/states") is False + assert api._verify_pour("https://192.168.1.10:8123/api/states") is False # Un hôte quelconque garde la vérification standard : un outil générique ne # doit pas devenir un trou de sécurité pour tout Internet. assert api._verify_pour("https://exemple.invalid/api") is True @@ -167,7 +167,7 @@ def test_rejeu_hote_ha_passe_par_ha_requests_session(monkeypatch): Bugbot : `verify=` seul ignore HA_TLS_SERVER_HOSTNAME. L'hôte HA doit rejouer via `ha_requests_session` (même chemin que MCP / vocal). """ - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") appels_session = [] class _Session: @@ -183,9 +183,9 @@ def _fake_request(method, url, **kwargs): monkeypatch.setattr(requests, "request", _fake_request) monkeypatch.setattr("core.ha_tls.ha_requests_session", lambda: _Session()) - sortie = api.call_api("http://192.168.1.x:8123/api/states") + sortie = api.call_api("http://192.168.1.10:8123/api/states") - assert appels_session == ["https://192.168.1.x:8123/api/states"] + assert appels_session == ["https://192.168.1.10:8123/api/states"] assert "22.4" in sortie diff --git a/tests/unit/test_call_api_token_ha_t332.py b/tests/unit/test_call_api_token_ha_t332.py index 64e029b..9120b6b 100644 --- a/tests/unit/test_call_api_token_ha_t332.py +++ b/tests/unit/test_call_api_token_ha_t332.py @@ -4,7 +4,7 @@ Suite directe de #T329, mesurée en production le 12/08 sur `4d8cdde`. Une fois la bascule HTTPS en place, l'appel atteint enfin HA — et se fait refouler : - 21:55:55,806 Appel API distant: GET http://192.168.1.x:8123/api/states + 21:55:55,806 Appel API distant: GET http://192.168.1.10:8123/api/states 21:55:55,813 [#T329] a fermé la connexion sans répondre → rejeu en https 21:55:55,929 [ha_agent] Erreur d'outil (auth) : Erreur (HTTP 401): Unauthorized 21:55:55,930 [ha_agent] Échec final après auto-correction infructueuse @@ -62,12 +62,12 @@ def request(self, method, url, headers=None, **kwargs): def test_token_injecte_pour_l_hote_ha(monkeypatch): """Le cas mesuré : plus de 401, l'appel part authentifié.""" - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") vus = {} _mock_session_ha(monkeypatch, vus) - api.call_api("https://192.168.1.x:8123/api/states") + api.call_api("https://192.168.1.10:8123/api/states") assert vus["headers"].get("Authorization") == "Bearer jeton-de-test" @@ -77,7 +77,7 @@ def test_aucune_injection_vers_un_hote_tiers(monkeypatch): Garde-fou de sécurité : `call_api` reçoit des URL écrites par le modèle. Le token ne doit jamais partir ailleurs que vers l'hôte HA configuré. """ - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") vus = {} @@ -104,13 +104,13 @@ def test_autorisation_fournie_remplacee_sur_l_hote_ha(monkeypatch): entier pour tout autre hôte : cf. `test_autorisation_fournie_intacte_hors_ha` dans `test_call_api_token_prioritaire_t340.py`. """ - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") vus = {} _mock_session_ha(monkeypatch, vus) api.call_api( - "https://192.168.1.x:8123/api/states", + "https://192.168.1.10:8123/api/states", headers_json='{"Authorization": "Bearer jeton-choisi"}', ) @@ -122,7 +122,7 @@ def test_injection_survit_a_la_bascule_https(monkeypatch): Cas prod : URL en clair + hôte HA. Plus de sonde HTTP : bascule immédiate vers HTTPS + injection Bearer (aucun token en clair sur le LAN). """ - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") appels = [] @@ -140,29 +140,29 @@ def _fake_request(method, url, headers=None, **kwargs): monkeypatch.setattr(requests, "request", _fake_request) monkeypatch.setattr("core.ha_tls.ha_requests_session", lambda: _Session()) - sortie = api.call_api("http://192.168.1.x:8123/api/states") + sortie = api.call_api("http://192.168.1.10:8123/api/states") - assert appels == [("https://192.168.1.x:8123/api/states", "Bearer jeton-de-test")] + assert appels == [("https://192.168.1.10:8123/api/states", "Bearer jeton-de-test")] assert "22.4" in sortie def test_jamais_de_bearer_sur_http_clair(monkeypatch): """Garde-fou : `_injecter_token_ha` refuse le schéma http://.""" - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") - headers = api._injecter_token_ha("http://192.168.1.x:8123/api/states", {}) + headers = api._injecter_token_ha("http://192.168.1.10:8123/api/states", {}) assert "Authorization" not in headers def test_token_absent_ne_leve_pas(monkeypatch): """Sans token configuré, l'appel part sans en-tête et l'API répondra 401.""" - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.delenv("HASS_TOKEN", raising=False) monkeypatch.delenv("HA_TOKEN", raising=False) vus = {} _mock_session_ha(monkeypatch, vus) - api.call_api("https://192.168.1.x:8123/api/states") + api.call_api("https://192.168.1.10:8123/api/states") assert "Authorization" not in vus["headers"] @@ -179,6 +179,6 @@ def _fake_request(method, url, headers=None, **kwargs): return _Reponse() monkeypatch.setattr(requests, "request", _fake_request) - api.call_api("https://192.168.1.x:8123/api/states") + api.call_api("https://192.168.1.10:8123/api/states") assert "Authorization" not in vus["headers"] diff --git a/tests/unit/test_call_api_token_prioritaire_t340.py b/tests/unit/test_call_api_token_prioritaire_t340.py index f6810a5..0b07692 100644 --- a/tests/unit/test_call_api_token_prioritaire_t340.py +++ b/tests/unit/test_call_api_token_prioritaire_t340.py @@ -69,13 +69,13 @@ def request(self, method, url, headers=None, **kwargs): def test_autorisation_inventee_par_le_modele_est_remplacee(monkeypatch): """Le cas de production : le placeholder du modèle cède la place au vrai token.""" - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") vus = {} _mock_session_ha(monkeypatch, vus) sortie = api.call_api( - "https://192.168.1.x:8123/api/states", + "https://192.168.1.10:8123/api/states", headers_json=json.dumps({"Authorization": PLACEHOLDER}), ) @@ -89,7 +89,7 @@ def test_remplacement_survit_a_la_bascule_https(monkeypatch): URL en clair ET un en-tête inventé. Les deux doivent être corrigés, et rien ne doit partir en clair sur le LAN. """ - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") appels = [] @@ -105,11 +105,11 @@ def _refus_clair(method, url, headers=None, **kwargs): monkeypatch.setattr("core.ha_tls.ha_requests_session", lambda: _Session()) api.call_api( - "http://192.168.1.x:8123/api/states", + "http://192.168.1.10:8123/api/states", headers_json=json.dumps({"Authorization": PLACEHOLDER}), ) - assert appels == [("https://192.168.1.x:8123/api/states", "Bearer jeton-de-test")] + assert appels == [("https://192.168.1.10:8123/api/states", "Bearer jeton-de-test")] def test_autorisation_fournie_intacte_hors_ha(monkeypatch): @@ -120,7 +120,7 @@ def test_autorisation_fournie_intacte_hors_ha(monkeypatch): transmet ce qu'on lui donne et n'injecte rien. Sans cette limite, le token HA partirait vers n'importe quelle URL écrite par le modèle. """ - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") vus = {} @@ -144,13 +144,13 @@ def test_casse_de_l_en_tete_indifferente(monkeypatch): HTTP les fusionnerait en une valeur illisible, et le 401 reviendrait sans qu'aucun journal ne l'explique. """ - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") vus = {} _mock_session_ha(monkeypatch, vus) api.call_api( - "https://192.168.1.x:8123/api/states", + "https://192.168.1.10:8123/api/states", headers_json=json.dumps({"authorization": PLACEHOLDER}), ) @@ -161,13 +161,13 @@ def test_casse_de_l_en_tete_indifferente(monkeypatch): def test_les_autres_en_tetes_sont_preserves(monkeypatch): """Seule l'autorisation est touchée : le reste (Content-Type…) passe intact.""" - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") vus = {} _mock_session_ha(monkeypatch, vus) api.call_api( - "https://192.168.1.x:8123/api/services/cover/close_cover", + "https://192.168.1.10:8123/api/services/cover/close_cover", method="POST", payload_json='{"entity_id": "cover.volet_salon"}', headers_json=json.dumps({"Authorization": PLACEHOLDER, "X-Trace": "abc"}), @@ -183,14 +183,14 @@ def test_sans_token_configure_l_en_tete_fourni_est_conserve(monkeypatch): Repli : si la configuration n'a aucun token, on ne dégrade pas l'appel en retirant ce que l'appelant avait mis — on n'a rien de mieux à proposer. """ - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.delenv("HASS_TOKEN", raising=False) monkeypatch.delenv("HA_TOKEN", raising=False) vus = {} _mock_session_ha(monkeypatch, vus) api.call_api( - "https://192.168.1.x:8123/api/states", + "https://192.168.1.10:8123/api/states", headers_json='{"Authorization": "Bearer jeton-de-l-appelant"}', ) @@ -202,14 +202,14 @@ def test_aucun_secret_dans_le_journal(monkeypatch, caplog): Le journal explique le remplacement sans publier de secret : ni le token configuré, ni la valeur reçue (qui pourrait en être un dans un autre montage). """ - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-tres-secret") vus = {} _mock_session_ha(monkeypatch, vus) with caplog.at_level("INFO", logger="tools.api"): api.call_api( - "https://192.168.1.x:8123/api/states", + "https://192.168.1.10:8123/api/states", headers_json='{"Authorization": "Bearer secret-de-l-appelant"}', ) @@ -221,11 +221,11 @@ def test_aucun_secret_dans_le_journal(monkeypatch, caplog): def test_injection_normale_inchangee(monkeypatch): """Non-régression #T332 : sans en-tête fourni, le comportement d'origine tient.""" - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") vus = {} _mock_session_ha(monkeypatch, vus) - api.call_api("https://192.168.1.x:8123/api/states") + api.call_api("https://192.168.1.10:8123/api/states") assert vus["headers"]["Authorization"] == "Bearer jeton-de-test" diff --git a/tests/unit/test_circuit_breaker.py b/tests/unit/test_circuit_breaker.py index da195b3..8abde63 100644 --- a/tests/unit/test_circuit_breaker.py +++ b/tests/unit/test_circuit_breaker.py @@ -4,8 +4,14 @@ Couvre notamment le bug C1 (audit V12) : le registre global doit exposer `_registry`, `_registry_lock` et `to_dict()`, attendus par core/llm_gateway.get_circuit_breakers_status(). + +Couvre aussi [#T352] : le délai de réouverture progressif (30 s, 1 min, 2 min…, +plafonné) et sa remise à la base après un succès en HALF_OPEN. Tous les tests +de ce lot utilisent un temps INJECTÉ (monkeypatch de time.time) — aucun sleep. """ +import logging + import pytest from core.llm.circuit_breaker import CircuitBreaker, CircuitBreakerState @@ -145,3 +151,128 @@ def test_live_latency_penalty_is_capped(): very_slow_cb.record_success(latency=60.0) # 60s, extrême assert get_live_latency_penalty("routing-latency-model-very-slow") == LIVE_LATENCY_MAX_PENALTY + + +# ────────────────────────────────────────────────────────────────── +# [#T352] Délai de réouverture progressif +# ────────────────────────────────────────────────────────────────── + +def _horloge_injectee(monkeypatch, start: float = 1000.0): + """Remplace time.time par une horloge contrôlée (liste mutable à incrémenter).""" + fake_time = [start] + monkeypatch.setattr("core.llm.circuit_breaker.time.time", lambda: fake_time[0]) + return fake_time + + +def test_delai_reouverture_croit_entre_ouvertures_consecutives(monkeypatch): + """TEST CENTRAL : un service qui échoue en boucle voit son délai CROÎTRE d'une ouverture à l'autre. + + Au moins trois paliers vérifiés (30 s → 1 min → 2 min → 4 min). Les ré-ouvertures + passent par la transition réelle OPEN → HALF_OPEN (délai écoulé) → échec → OPEN, + pilotée par le temps injecté. + """ + fake_time = _horloge_injectee(monkeypatch) + cb = CircuitBreaker(name="t352-progressif", failure_threshold=1, recovery_timeout=30.0) + + # 1re ouverture (depuis CLOSED) : base inchangée + cb.record_failure(Exception("boom")) + assert cb.recovery_timeout == 30.0 + assert cb._consecutive_trips == 1 + + # 2e ouverture consécutive : délai écoulé → HALF_OPEN → échec → ré-ouverture → 1 min + fake_time[0] += 31.0 + assert cb.state == CircuitBreakerState.HALF_OPEN + cb.record_failure(Exception("boom")) + assert cb.recovery_timeout == 60.0 + + # 3e ouverture consécutive : 2 min + fake_time[0] += 61.0 + assert cb.state == CircuitBreakerState.HALF_OPEN + cb.record_failure(Exception("boom")) + assert cb.recovery_timeout == 120.0 + + # 4e ouverture consécutive : 4 min + fake_time[0] += 121.0 + assert cb.state == CircuitBreakerState.HALF_OPEN + cb.record_failure(Exception("boom")) + assert cb.recovery_timeout == 240.0 + + +def test_delai_reouverture_plafonne_sans_depasser_le_max(): + """Le délai croissant est plafonné : il ne dépasse jamais max_recovery_timeout.""" + cb = CircuitBreaker( + name="t352-plafond", + failure_threshold=1, + recovery_timeout=30.0, + max_recovery_timeout=180.0, + ) + # 30, 60, 120, puis plafonné à 180 à partir de la 4e ouverture + for _ in range(10): + cb.record_failure(Exception("boom")) + cb._state = CircuitBreakerState.HALF_OPEN + assert cb._consecutive_trips == 10 + assert cb.recovery_timeout == 180.0 + + +def test_succes_en_half_open_remet_le_delai_a_la_base(monkeypatch): + """TEST JUMEAU : un succès en HALF_OPEN remet le délai à sa valeur de base. + + Sans cela, un incident passager pénaliserait un provider sain pendant une + demi-heure — pire que le défaut d'origine. + """ + fake_time = _horloge_injectee(monkeypatch) + cb = CircuitBreaker(name="t352-jumeau", failure_threshold=1, recovery_timeout=30.0) + + # Faire croître le délai jusqu'à 2 min (3 ouvertures consécutives) + cb.record_failure(Exception("boom")) + fake_time[0] += 31.0 + assert cb.state == CircuitBreakerState.HALF_OPEN + cb.record_failure(Exception("boom")) + fake_time[0] += 61.0 + assert cb.state == CircuitBreakerState.HALF_OPEN + cb.record_failure(Exception("boom")) + assert cb.recovery_timeout == 120.0 + assert cb._consecutive_trips == 3 + + # Succès en HALF_OPEN → remise à la base et fermeture + fake_time[0] += 121.0 + assert cb.state == CircuitBreakerState.HALF_OPEN + cb.record_success() + assert cb.state == CircuitBreakerState.CLOSED + assert cb.recovery_timeout == 30.0 + assert cb._consecutive_trips == 0 + + +def test_premiere_ouverture_inchangee_pour_appelant_sans_params(): + """La première ouverture reste à 30 s pour un appelant qui ne passe que name. + + Rétro-compatibilité : `get_or_create(name)` seul doit conserver exactement le + comportement d'aujourd'hui (seuil 3, délai de base 30 s). + """ + cb = CircuitBreaker.get_or_create("t352-premiere-ouverture") + cb.record_failure(Exception("boom")) + cb.record_failure(Exception("boom")) + cb.record_failure(Exception("boom")) + assert cb.state == CircuitBreakerState.OPEN + assert cb.recovery_timeout == 30.0 + assert cb._consecutive_trips == 1 + + +def test_delai_retenu_journalise_a_chaque_ouverture(caplog): + """Le délai retenu est journalisé à chaque ouverture (vérifiable en prod).""" + cb = CircuitBreaker(name="t352-log", failure_threshold=1, recovery_timeout=30.0) + with caplog.at_level(logging.ERROR, logger="core.llm.circuit_breaker"): + cb.record_failure(Exception("boom")) + assert "pour 30s" in caplog.text + assert "ouverture consécutive n°1" in caplog.text + + +def test_get_stats_expose_la_progression(): + """get_stats() expose les nouveaux champs de progression pour l'observabilité.""" + cb = CircuitBreaker(name="t352-stats", failure_threshold=1, recovery_timeout=30.0) + cb.record_failure(Exception("boom")) + stats = cb.get_stats() + assert stats["recovery_timeout"] == 30.0 + assert stats["base_recovery_timeout"] == 30.0 + assert stats["max_recovery_timeout"] == CircuitBreaker.DEFAULT_MAX_RECOVERY_TIMEOUT + assert stats["consecutive_trips"] == 1 diff --git a/tests/unit/test_cleanup_zombie_dag.py b/tests/unit/test_cleanup_zombie_dag.py new file mode 100644 index 0000000..c0ffc4e --- /dev/null +++ b/tests/unit/test_cleanup_zombie_dag.py @@ -0,0 +1,193 @@ +""" +tests/unit/test_cleanup_zombie_dag.py — Réconciliation des tâches DAG des +sessions zombies (#T362). + +Vérifie que cleanup_zombie_sessions, en plus de marquer les sessions zombies +'error', réconcilie DANS LA MÊME TRANSACTION les tâches non terminales +(pending/running) rattachées à ces sessions : elles passent 'error' avec un +ended_at renseigné. Les garde-fous sont prouvés : +- une session running RÉCENTE et ses tâches ne sont pas touchées ; +- une session waiting_approval ANCIENNE et ses tâches ne sont pas touchées + (HITL en attente légitime) ; +- les tâches déjà success d'une session zombifiée gardent leur statut ; +- la valeur de retour reste le nombre de SESSIONS nettoyées. + +Aucune écriture dans la base partagée du poste : moteur_runtime.db est isolée +dans tmp_path (même motif que test_cleanup_sessions_zombies). +""" + +import time + +import pytest + +import core.runtime_db as runtime_db +import core.session_history as sh + +# ────────────────────────────────────────────────────────────────── +# Fixtures & helpers +# ────────────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def _base_isolee(tmp_path, monkeypatch): + """Isolation stricte : moteur_runtime.db dans tmp_path, jamais la base réelle.""" + monkeypatch.setattr(runtime_db, "_DB_PATH", str(tmp_path / "moteur_runtime.db")) + + +def _creer_session(session_id: str, statut: str = "running") -> None: + """Crée une session via le chemin réel, puis force son statut.""" + sh.record_session_start(session_id, "Objectif de test", "planner") + conn = runtime_db.get_connection() + conn.execute("UPDATE sessions SET status = ? WHERE session_id = ?", (statut, session_id)) + conn.commit() + conn.close() + + +def _vieillir(session_id: str, age_secondes: float) -> None: + """Vieillit une session (started_at ET last_activity) sans attente réelle.""" + conn = runtime_db.get_connection() + conn.execute( + "UPDATE sessions SET started_at = ?, last_activity = ? WHERE session_id = ?", + (time.time() - age_secondes, time.time() - age_secondes, session_id), + ) + conn.commit() + conn.close() + + +def _creer_tache(session_id: str, task_id: str, statut: str) -> None: + """Crée une tâche DAG pour une session.""" + conn = runtime_db.get_connection() + conn.execute( + """ + INSERT INTO dag_tasks (session_id, task_id, status, worker_id) + VALUES (?, ?, ?, 'local') + """, + (session_id, task_id, statut), + ) + conn.commit() + conn.close() + + +def _tache(session_id: str, task_id: str) -> tuple | None: + """Retourne (status, ended_at) d'une tâche, None si absente.""" + conn = runtime_db.get_connection() + row = conn.execute( + "SELECT status, ended_at FROM dag_tasks WHERE session_id = ? AND task_id = ?", + (session_id, task_id), + ).fetchone() + conn.close() + return row + + +# ────────────────────────────────────────────────────────────────── +# Test central : réconciliation des tâches d'une session zombie +# ────────────────────────────────────────────────────────────────── + +def test_session_zombie_reconcilie_ses_taches_non_terminales(): + """[Central] Une session running ancienne portant pending + running : + après nettoyage, la session est 'error' ET les deux tâches sont terminales + avec un ended_at renseigné.""" + _creer_session("dag_zombie") + _vieillir("dag_zombie", 2 * 3600) + _creer_tache("dag_zombie", "t1", "pending") + _creer_tache("dag_zombie", "t2", "running") + + nettoyees = sh.cleanup_zombie_sessions() + + assert nettoyees == 1 + # La session est bien zombifiée. + conn = runtime_db.get_connection() + row = conn.execute( + "SELECT status FROM sessions WHERE session_id = ?", ("dag_zombie",) + ).fetchone() + conn.close() + assert row[0] == "error" + + # Les deux tâches sont terminales, avec ended_at renseigné. + for task_id in ("t1", "t2"): + status, ended_at = _tache("dag_zombie", task_id) + assert status == "error" + assert ended_at is not None + + +# ────────────────────────────────────────────────────────────────── +# Garde-fous +# ────────────────────────────────────────────────────────────────── + +def test_session_recente_et_ses_taches_ne_sont_pas_touchees(): + """Une session running RÉCENTE et ses tâches ne sont pas touchées.""" + _creer_session("dag_recente") + _vieillir("dag_recente", 600) # 10 minutes seulement + _creer_tache("dag_recente", "t1", "pending") + _creer_tache("dag_recente", "t2", "running") + + assert sh.cleanup_zombie_sessions() == 0 + assert _tache("dag_recente", "t1")[0] == "pending" + assert _tache("dag_recente", "t2")[0] == "running" + + +def test_session_waiting_approval_ancienne_et_ses_taches_non_touchees(): + """[Garde-fou HITL] Une session waiting_approval ANCIENNE et ses tâches ne + sont pas touchées : le prédicat ne doit pas être élargi.""" + _creer_session("dag_hitl", statut="waiting_approval") + _vieillir("dag_hitl", 5 * 3600) + _creer_tache("dag_hitl", "t1", "pending") + _creer_tache("dag_hitl", "t2", "running") + + assert sh.cleanup_zombie_sessions() == 0 + assert _tache("dag_hitl", "t1")[0] == "pending" + assert _tache("dag_hitl", "t2")[0] == "running" + + +def test_taches_deja_success_de_session_zombifiee_gardent_leur_statut(): + """Les tâches déjà terminales (success) d'une session zombifiée ne sont pas + écrasées ; seules les tâches non terminales sont réconciliées.""" + _creer_session("dag_mixte") + _vieillir("dag_mixte", 2 * 3600) + _creer_tache("dag_mixte", "terminee", "success") + _creer_tache("dag_mixte", "en_cours", "running") + + assert sh.cleanup_zombie_sessions() == 1 + + status_terminee, ended_at_terminee = _tache("dag_mixte", "terminee") + assert status_terminee == "success" + assert ended_at_terminee is None # inchangée + + status_en_cours, ended_at_en_cours = _tache("dag_mixte", "en_cours") + assert status_en_cours == "error" + assert ended_at_en_cours is not None + + +# ────────────────────────────────────────────────────────────────── +# Contrat de retour & erreur_message +# ────────────────────────────────────────────────────────────────── + +def test_valeur_retour_reste_le_nombre_de_sessions(): + """La valeur de retour reste le nombre de SESSIONS nettoyées (contrat + existant, appelé et journalisé par gui_server), pas le nombre de tâches.""" + _creer_session("dag_a") + _vieillir("dag_a", 2 * 3600) + _creer_tache("dag_a", "t1", "pending") + _creer_tache("dag_a", "t2", "running") + _creer_session("dag_b") + _vieillir("dag_b", 2 * 3600) + _creer_tache("dag_b", "t1", "running") + + # 2 sessions zombies, 3 tâches non terminales au total. + assert sh.cleanup_zombie_sessions() == 2 + + +def test_tache_reconciliee_a_un_message_derreur_explicite(): + """Les tâches réconciliées portent un error_message explicite.""" + _creer_session("dag_message") + _vieillir("dag_message", 2 * 3600) + _creer_tache("dag_message", "t1", "pending") + + sh.cleanup_zombie_sessions() + + conn = runtime_db.get_connection() + row = conn.execute( + "SELECT error_message FROM dag_tasks WHERE session_id = ? AND task_id = ?", + ("dag_message", "t1"), + ).fetchone() + conn.close() + assert row[0] and "réconciliée" in row[0] diff --git a/tests/unit/test_cloture_session_fin_execution_t379.py b/tests/unit/test_cloture_session_fin_execution_t379.py new file mode 100644 index 0000000..ea15506 --- /dev/null +++ b/tests/unit/test_cloture_session_fin_execution_t379.py @@ -0,0 +1,360 @@ +""" +tests/unit/test_cloture_session_fin_execution_t379.py — Une exécution qui se +termine doit le dire (#T379). + +Observation du 17/08/2026, 20 h : trois lots soumis au moteur, journal et base +à l'appui. Les trois DAG se sont terminés à 20:06 — un succès, deux échecs — +journalisés par `services/approval_resume_service.py` (« Demande … rejouée — +succès / échec »). Vingt minutes plus tard, la base disait encore : +sessions `status='running'`, dag_tasks 13 `pending` / 3 `error` / 3 `success`. +Plus rien ne tournait, et rien ne le disait : ces sessions seraient devenues +des « zombies » une heure plus tard par ramassage, diagnostiquées comme une +panne alors qu'elles s'étaient simplement terminées sans le dire. + +Ce que ces tests verrouillent : +- LE TEST CENTRAL : une reprise après approbation qui se termine en ÉCHEC + laisse une session au statut terminal avec une date de fin — plus jamais + `running` (exactement le cas observé le 17/08) ; +- même chose pour une reprise qui réussit ; +- même chose pour une exception levée en cours de rejeu ; +- une session encore en cours n'est JAMAIS marquée terminée ; +- une session en attente d'approbation (HITL) reste intacte ; +- le rejet humain du plan clôture lui aussi la session ; +- le moteur NOMME la cause de l'échec (DAG, Reviewer, contrat) au lieu de la + réduire à un booléen. +""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from core import hitl_store, runtime_db +from core.session_history import ( + get_session_detail, + record_session_end, + record_session_start, +) + + +@pytest.fixture(autouse=True) +def _base_isolee(tmp_path): + """Chaque test écrit dans sa propre base runtime (motif des tests HITL).""" + ancien = runtime_db.get_db_path() + runtime_db.override_db_path(str(tmp_path / "runtime_test.db")) + runtime_db.get_connection().close() # déclenche _init_schema + yield + runtime_db.override_db_path(ancien) + + +def _dag_serialise() -> list[dict]: + return [ + {"task_objective": "Créer le livrable", "task_id": "t1", "depends_on": [], + "available_tools": ["write_file"], "relevant_context": "", "metadata": {}, + "status": "pending", "assigned_agent": None, "result_summary": None}, + ] + + +def _demande(request_id: str, session_id: str) -> None: + """Enregistre une demande HITL pour la session, sans la décider encore.""" + hitl_store.enregistrer_demande( + request_id=request_id, session_id=session_id, + dag_tasks=_dag_serialise(), description="1 tâche à risque", + ) + + +def _brancher_moteur( + monkeypatch, + engine, +) -> None: + """ + Fait monter ce moteur précis par la reprise, sans rien construire d'autre. + + `monter_moteur` et `ExecutionBudget.from_config` sont interceptés comme + dans les tests du plancher du 17/08 : la reprise doit tourner sur un + moteur contrôlé, jamais sur les vrais providers. + """ + bridge = MagicMock() + bridge.start = AsyncMock() + bridge.stop = AsyncMock() + + def _faux_moteur(session_id, objectif, config): + return engine, MagicMock(), bridge, MagicMock() + + monkeypatch.setattr("services.pipeline_service.monter_moteur", _faux_moteur) + monkeypatch.setattr( + "core.execution_budget.ExecutionBudget.from_config", + classmethod(lambda cls, session_id: MagicMock(max_tokens=1000)), + ) + monkeypatch.setattr("core.llm_gateway.load_config", lambda: {}) + + +def _moteur_reel(session_id: str): + """Engine réel sans agents : seuls le DAG et les contrôles sont exercés.""" + from core.engine import Engine + engine = Engine(session_id=session_id) + # L'enregistrement des skills écrit en base : hors périmètre de ces tests. + engine._record_skills_from_dag = lambda *a, **k: None + return engine + + +# ── LE test central : la reprise en ÉCHEC clôture la session (cas du 17/08) ─ + + +@pytest.mark.asyncio +async def test_reprise_en_echec_cloture_la_session(monkeypatch): + """ + Rejoue la scène du 17/08 : une session suspendue puis reprise, dont le DAG + rejoué échoue, doit porter un statut terminal et une date de fin — pas + rester `running` jusqu'au ramassage des zombies. + """ + from services.approval_resume_service import reprendre_apres_approbation + + session_id = "bg_93d1657b" + record_session_start(session_id, "objectif du lot", "planner") + # La suspension avait clos la session en attente d'approbation. + record_session_end(session_id, "waiting_approval") + _demande("r_echec", session_id) + hitl_store.marquer_decision("r_echec", approuve=True) + + engine = _moteur_reel(session_id) + + async def _dag_en_erreur(**kwargs): + return {"t1": "error"}, True + + async def _contrat_en_echec(on_event=None): + return False # le contrat CONFIRME l'échec du DAG + + engine._dag_runner.execute_dag = _dag_en_erreur + engine._review_loop.contrat_seul = _contrat_en_echec + _brancher_moteur(monkeypatch, engine) + + res = await reprendre_apres_approbation("r_echec") + + assert res["status"] == "error" + detail = get_session_detail(session_id) + assert detail["status"] == "error", "la session doit porter un statut terminal" + assert detail["status"] != "running", "plus jamais « running » après la fin du DAG" + assert detail["ended_at"] is not None, "la date de fin s'écrit au moment de l'arrêt" + assert "Contrat d'acceptation non satisfait" in (detail["error_message"] or ""), \ + "la cause décidée par le moteur doit être lisible dans la session" + + +@pytest.mark.asyncio +async def test_reprise_en_succes_cloture_la_session(monkeypatch): + """Contre-épreuve : la reprise qui réussit écrit elle aussi la clôture.""" + from services.approval_resume_service import reprendre_apres_approbation + + session_id = "bg_1a3aa453" + record_session_start(session_id, "objectif du lot", "planner") + record_session_end(session_id, "waiting_approval") + _demande("r_succes", session_id) + hitl_store.marquer_decision("r_succes", approuve=True) + + engine = _moteur_reel(session_id) + + async def _dag_reussi(**kwargs): + return {"t1": "success"}, False + + engine._dag_runner.execute_dag = _dag_reussi + _brancher_moteur(monkeypatch, engine) + + res = await reprendre_apres_approbation("r_succes") + + assert res["status"] == "completed" + detail = get_session_detail(session_id) + assert detail["status"] == "success" + assert detail["ended_at"] is not None + + +@pytest.mark.asyncio +async def test_exception_en_cours_de_rejeu_cloture_la_session(monkeypatch): + """Une exception levée pendant le rejeu est une fin d'exécution comme une autre.""" + from services.approval_resume_service import reprendre_apres_approbation + + session_id = "bg_b26bc903" + record_session_start(session_id, "objectif du lot", "planner") + record_session_end(session_id, "waiting_approval") + _demande("r_exception", session_id) + hitl_store.marquer_decision("r_exception", approuve=True) + + engine = _moteur_reel(session_id) + + async def _dag_explose(**kwargs): + raise RuntimeError("perte de connexion MCP pendant le rejeu") + + engine._dag_runner.execute_dag = _dag_explose + _brancher_moteur(monkeypatch, engine) + + res = await reprendre_apres_approbation("r_exception") + + assert res["status"] == "error" + detail = get_session_detail(session_id) + assert detail["status"] == "error" + assert detail["ended_at"] is not None + assert "perte de connexion MCP" in (detail["error_message"] or "") + + +@pytest.mark.asyncio +async def test_reprise_avortee_dag_vide_cloture_la_session(monkeypatch): + """Une reprise sans DAG exploitable s'arrête définitivement — et le dit.""" + from services.approval_resume_service import reprendre_apres_approbation + + session_id = "bg_dag_vide" + record_session_start(session_id, "objectif du lot", "planner") + record_session_end(session_id, "waiting_approval") + hitl_store.enregistrer_demande( + request_id="r_vide_close", session_id=session_id, dag_tasks=[], description="", + ) + hitl_store.marquer_decision("r_vide_close", approuve=True) + + res = await reprendre_apres_approbation("r_vide_close") + + assert res["status"] == "vide" + detail = get_session_detail(session_id) + assert detail["status"] == "error" + assert detail["ended_at"] is not None + + +# ── Garde-fous : jamais de clôture pour une exécution qui n'est pas finie ──── + + +@pytest.mark.asyncio +async def test_session_en_cours_jamais_marquee_terminee(): + """ + Une demande encore `pending` n'est pas une fin d'exécution : la reprise + refuse de rejouer et la session reste `running`, sans date de fin. + """ + from services.approval_resume_service import reprendre_apres_approbation + + session_id = "bg_encours" + record_session_start(session_id, "objectif en cours", "planner") + _demande("r_encore", session_id) # jamais approuvée + + res = await reprendre_apres_approbation("r_encore") + + assert res["status"] == "non_approuve" + detail = get_session_detail(session_id) + assert detail["status"] == "running", "une session en cours n'est jamais close" + assert detail["ended_at"] is None + + +@pytest.mark.asyncio +async def test_session_en_attente_d_approbation_reste_intacte(): + """ + HITL : une session suspendue peut légitimement attendre des heures. + Tant que la décision n'est pas prise, rien ne doit toucher à son statut. + """ + from services.approval_resume_service import reprendre_apres_approbation + + session_id = "bg_attente" + record_session_start(session_id, "objectif suspendu", "planner") + record_session_end(session_id, "waiting_approval") + _demande("r_attente", session_id) # toujours pending + + res = await reprendre_apres_approbation("r_attente") + + assert res["status"] == "non_approuve" + detail = get_session_detail(session_id) + assert detail["status"] == "waiting_approval", \ + "une attente d'approbation n'est pas une fin d'exécution" + + +@pytest.mark.asyncio +async def test_rejet_humain_cloture_la_session(): + """Rejeter le plan est une fin d'exécution définitive : la session le porte.""" + from services.approval_resume_service import annuler_apres_rejet + + session_id = "bg_rejete" + record_session_start(session_id, "objectif rejeté", "planner") + record_session_end(session_id, "waiting_approval") + _demande("r_rejete", session_id) + hitl_store.marquer_decision("r_rejete", approuve=False) + + res = annuler_apres_rejet("r_rejete") + + assert res["status"] == "rien_a_annuler" # pas de branche Git dans ce test + detail = get_session_detail(session_id) + assert detail["status"] == "error" + assert detail["ended_at"] is not None + assert "rejeté par l'utilisateur" in (detail["error_message"] or "").lower() + + +# ── Le moteur nomme la cause au lieu de la réduire à un booléen ────────────── + + +@pytest.mark.asyncio +async def test_cause_echec_revue_rejetee(): + """Rejet du Reviewer : la cause est nommée pour la clôture de session.""" + from core.state import TaskPayload + + engine = _moteur_reel("sess_cause_revue") + engine.agents["reviewer"] = MagicMock() # revue activée : reviewer présent + engine._is_review_enabled = lambda: True + + async def _dag_reussi(**kwargs): + return {"t1": "success"}, False + + async def _revue_rejette(initial_objective, max_rounds=3, on_event=None): + return False + + engine._dag_runner.execute_dag = _dag_reussi + engine._review_loop.run_review = _revue_rejette + + taches = [TaskPayload(task_objective="tâche", task_id="t1")] + _tasks_status, has_error = await engine.executer_dag_et_controles( + dag_tasks=taches, objectif="obj", max_session_tokens=1000, + ) + + assert has_error is True + assert "Reviewer" in (engine.cause_echec or "") + + +@pytest.mark.asyncio +async def test_cause_echec_contrat_confirme_l_echec(): + """DAG en erreur + contrat non satisfait : le contrat devient la cause.""" + from core.state import TaskPayload + + engine = _moteur_reel("sess_cause_contrat") + + async def _dag_en_erreur(**kwargs): + return {"t1": "error"}, True + + async def _contrat_en_echec(on_event=None): + return False + + engine._dag_runner.execute_dag = _dag_en_erreur + engine._review_loop.contrat_seul = _contrat_en_echec + + taches = [TaskPayload(task_objective="tâche", task_id="t1")] + _tasks_status, has_error = await engine.executer_dag_et_controles( + dag_tasks=taches, objectif="obj", max_session_tokens=1000, + ) + + assert has_error is True + assert "Contrat d'acceptation non satisfait" in (engine.cause_echec or "") + + +@pytest.mark.asyncio +async def test_contrat_satisfait_absout_et_efface_la_cause(): + """Contre-épreuve : contrat prouvé → succès, et plus aucune cause d'échec.""" + from core.state import TaskPayload + + engine = _moteur_reel("sess_cause_absoute") + + async def _dag_en_erreur(**kwargs): + return {"t1": "error"}, True + + async def _contrat_satisfait(on_event=None): + return True + + engine._dag_runner.execute_dag = _dag_en_erreur + engine._review_loop.contrat_seul = _contrat_satisfait + + taches = [TaskPayload(task_objective="tâche", task_id="t1")] + tasks_status, has_error = await engine.executer_dag_et_controles( + dag_tasks=taches, objectif="obj", max_session_tokens=1000, + ) + + assert has_error is False + assert engine.cause_echec is None + assert tasks_status["t1"] == "success" # absolution aussi pour la branche Git diff --git a/tests/unit/test_contrat_plancher_incident_17_08.py b/tests/unit/test_contrat_plancher_incident_17_08.py new file mode 100644 index 0000000..ddd7ddd --- /dev/null +++ b/tests/unit/test_contrat_plancher_incident_17_08.py @@ -0,0 +1,555 @@ +""" +test_contrat_plancher_incident_17_08.py — Le contrat approuvé devient un plancher. + +Rejoue l'incident du 17/08/2026 (journal à l'appui) : un lot dont le livrable +(`tests/unit/test_regression_prod_17_08.py`) n'a JAMAIS été créé a été déclaré +satisfait, parce que : + +1. le Planner du round correctif avait posé un NOUVEAU contrat portant sur deux + fichiers du dépôt qui existaient depuis des mois ; +2. `ReviewLoop._criteres_du_plan()` prenait le contrat le plus RÉCENT de + l'historique — le contrat approuvé, toujours présent mais plus bas dans la + pile, n'était jamais consulté ; +3. la porte déterministe (contrat satisfait → `approved = True`) a donc été + ouverte avec une clé refabriquée par celui qu'on juge. + +Trois verrous posés par ce correctif, et testés ici : +- PLANCHER : `_criteres_du_plan()` rend l'UNION des contrats de l'historique ; + un plan correctif peut enrichir le contrat, jamais le remplacer ni + l'affaiblir ; +- ANTI-TAUTOLOGIE : l'état des critères fichier est constaté À LA POSE du plan + (`constater_etat_initial`) ; un critère déjà vrai avant le travail est + « satisfait d'avance » et ne compte ni comme preuve ni comme verdict — un + contrat entièrement satisfait d'avance ne peut plus absoudre un DAG en erreur ; +- VISIBILITÉ : chaque verdict journalise la provenance des critères évalués + (plancher ou plan correctif) et les substitutions appliquées. + +Arbitrage retenu (documenté dans la PR) : un critère du plancher devenu +impossible peut être remplacé par une SUBSTITUTION DÉCLARÉE, tracée et +journalisée — jamais par un remplacement silencieux. +""" + +import asyncio +import os +import sys +from unittest.mock import AsyncMock, MagicMock + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core import hitl_store, runtime_db +from core.acceptance_contract import ( + CLE_CONTRAT, + CLE_ETAT_INITIAL, + CLE_SUBSTITUTIONS, + Critere, + RapportContrat, + ResultatCritere, + constater_etat_initial, + union_contrats, + verifier_contrat, +) +from core.review_loop import ReviewLoop +from core.state import GlobalState, StateUpdate + + +@pytest.fixture(autouse=True) +def _base_isolee(tmp_path): + """Chaque test écrit dans sa propre base runtime (motif des tests HITL).""" + ancien = runtime_db.get_db_path() + runtime_db.override_db_path(str(tmp_path / "runtime_test.db")) + runtime_db.get_connection().close() + yield + runtime_db.override_db_path(ancien) + + +@pytest.fixture +def espace(tmp_path, monkeypatch): + """Déplace la frontière d'espace de travail sur un dossier temporaire.""" + racine = os.path.realpath(str(tmp_path)) + monkeypatch.setattr("tools.system._WORKSPACE_ROOT", racine) + return racine + + +class _EngineMock: + def __init__(self): + self.state = GlobalState(session_id="sess_plancher") + self._history_lock = asyncio.Lock() + self.agents = {} + + +def _poser_contrat(engine, criteres, etat_initial=None, substitutions=None, agent="planner"): + """Ajoute à l'historique un StateUpdate porteur de contrat, comme le Planner.""" + metadata = {CLE_CONTRAT: criteres} + if etat_initial is not None: + metadata[CLE_ETAT_INITIAL] = etat_initial + if substitutions: + metadata[CLE_SUBSTITUTIONS] = substitutions + engine.state.history.append(StateUpdate( + agent_name=agent, status="success", metadata=metadata, + )) + + +FICHIER_LIVRABLE = "test_regression_prod_17_08.py" + + +def _critere_livrable_absent(espace) -> dict: + """Le critère du contrat approuvé : le livrable doit exister. Le chemin + est absolu et DANS l'espace de travail, pour que « absent » soit un échec + net (et non « hors espace » qui serait non vérifiable).""" + return {"type": "fichier_existe", "valeur": os.path.join(espace, FICHIER_LIVRABLE), + "description": "Le test de régression est créé"} + + +# ── LE test central : rejouer la scène du 17/08 ───────────────────────────── + + +class TestSceneDu17Aout: + """ + Historique : (a) contrat approuvé exigeant un fichier ABSENT, puis + (b) contrat correctif composé uniquement de critères satisfaits d'avance + sur des fichiers préexistants. Le verdict DOIT être un échec, et le + journal doit nommer le critère hérité qui a échoué. C'est exactement le + cas qui avait produit un faux succès. + """ + + def test_le_plancher_survit_au_contrat_correctif_tautologique(self, espace, monkeypatch, caplog): + monkeypatch.delenv("MOTEUR_CONTRAT_SUR_ECHEC", raising=False) + engine = _EngineMock() + + # (a) Le contrat approuvé : le livrable doit exister. Il n'existe pas. + _poser_contrat(engine, [_critere_livrable_absent(espace)], etat_initial={0: False}) + + # (b) Le contrat correctif fautif : deux fichiers du dépôt qui + # existent depuis des mois, constatés satisfaits d'avance à leur pose. + preexistant_a = os.path.join(espace, "ha_state_query.py") + preexistant_b = os.path.join(espace, "vocal_host.py") + open(preexistant_a, "w").close() + open(preexistant_b, "w").close() + correctif = [ + {"type": "fichier_existe", "valeur": preexistant_a, + "description": "Vérifie que le fichier ha_state_query.py est bien accessible."}, + {"type": "fichier_existe", "valeur": preexistant_b, + "description": "Vérifie que le fichier vocal_host.py est bien accessible."}, + ] + _poser_contrat(engine, correctif, etat_initial={0: True, 1: True}) + + boucle = ReviewLoop(engine) + import logging + with caplog.at_level(logging.INFO, logger="core.review_loop"): + verdict = asyncio.run(boucle.contrat_seul()) + + # Avant le correctif, cette scène rendait True (faux succès). + assert verdict is False + # Le critère hérité qui a échoué est NOMMÉ dans le journal. + assert any(FICHIER_LIVRABLE in r.message for r in caplog.records), ( + "le journal doit nommer le critère du plancher en échec" + ) + assert any("plancher" in r.message for r in caplog.records), ( + "la trace doit dire que le critère provient du contrat d'origine" + ) + + def test_les_criteres_tautologiques_sont_reels_mais_exclus_du_verdict(self, espace): + """Contre-épreuve mécanique : les critères correctifs passent bien, + seul le plancher échoue — c'est lui qui doit trancher.""" + preexistant = os.path.join(espace, "deja_la.py") + open(preexistant, "w").close() + + union = union_contrats([ + [_critere_livrable_absent(espace)], + [{"type": "fichier_existe", "valeur": preexistant}], + ]) + rapport = verifier_contrat(union, etats_initiaux={1: True}) + + assert len(rapport.resultats) == 2 + assert len(rapport.verifiables) == 1 # le critère d'avance est exclu + assert rapport.echecs[0].critere.valeur.endswith(FICHIER_LIVRABLE) + assert rapport.satisfait is False + + +# ── Le cas légitime doit survivre : enrichir, oui ──────────────────────────── + + +class TestEnrichissementLegitime: + def test_un_plan_correctif_qui_ajoute_un_critere_reste_un_succes(self, espace, monkeypatch): + """Contrat d'origine + un critère supplémentaire posé par la + correction, tous deux produits par le lot → succès, sans appel LLM.""" + monkeypatch.setattr(ReviewLoop, "_has_modified_files", lambda self: True) + livrable = os.path.join(espace, "livrable.py") + fichier_correction = os.path.join(espace, "correction.py") + open(livrable, "w").close() + open(fichier_correction, "w").close() + + engine = _EngineMock() + # Le lot a créé le fichier depuis la pose du contrat d'origine : + # le constat d'avant-travail disait « absent ». + _poser_contrat(engine, [{"type": "fichier_existe", "valeur": livrable}], + etat_initial={0: False}) + _poser_contrat(engine, [{"type": "fichier_existe", "valeur": fichier_correction}], + etat_initial={0: False}) + + boucle = ReviewLoop(engine) + reviewer = MagicMock() + reviewer.invoke = AsyncMock() + engine.agents["reviewer"] = reviewer + + assert asyncio.run(boucle.run_review("objectif")) is True + reviewer.invoke.assert_not_called() # la porte déterministe tranche seule + + def test_l_union_contient_tous_les_criteres(self): + engine = _EngineMock() + _poser_contrat(engine, [{"type": "commande", "valeur": "pytest"}]) + _poser_contrat(engine, [{"type": "fichier_existe", "valeur": "ajout.py"}]) + + criteres = ReviewLoop(engine)._criteres_du_plan() + + valeurs = {c["valeur"] for c in criteres} + assert valeurs == {"pytest", "ajout.py"}, "l'ancien critère a disparu de l'union" + + +# ── Anti-tautologie : un contrat satisfait d'avance ne prouve rien ────────── + + +class TestContratSatisfaitDAvance: + def test_un_contrat_entierement_satisfait_d_avance_n_absout_pas(self, espace, monkeypatch): + """DAG en erreur + contrat correctif dont chaque critère était vrai + avant le lot : non déterminable → l'appelant garde son comportement + d'avant (l'erreur du DAG n'est PAS absoute).""" + monkeypatch.delenv("MOTEUR_CONTRAT_SUR_ECHEC", raising=False) + preexistant = os.path.join(espace, "ancien.py") + open(preexistant, "w").close() + + engine = _EngineMock() + _poser_contrat(engine, [{"type": "fichier_existe", "valeur": preexistant}], + etat_initial={0: True}) + + boucle = ReviewLoop(engine) + assert asyncio.run(boucle.contrat_seul()) is None + + def test_un_critere_satisfait_d_avance_ne_compte_pas_comme_preuve(self, espace): + preexistant = os.path.join(espace, "temoin.py") + open(preexistant, "w").close() + + rapport = verifier_contrat( + [{"type": "fichier_existe", "valeur": preexistant}], + etats_initiaux={0: True}, + ) + assert rapport.determinable is False + assert rapport.satisfait is False + # Le constat reste VISIBLE dans le rapport, pas silencieux. + assert len(rapport.resultats_satisfaits_d_avance) == 1 + assert "satisfait d'avance" in rapport.resume() + + def test_sans_constat_le_critere_reste_une_preuve(self, espace): + """Le doute rend le jugement plus strict, jamais plus permissif : + sans constat d'état initial, le critère est évalué normalement.""" + preexistant = os.path.join(espace, "temoin2.py") + open(preexistant, "w").close() + + rapport = verifier_contrat([{"type": "fichier_existe", "valeur": preexistant}]) + assert rapport.satisfait is True + + def test_constater_etat_initial_ne_constate_que_les_fichiers(self, espace): + existant = os.path.join(espace, "la.py") + open(existant, "w").close() + + etats = constater_etat_initial([ + {"type": "fichier_existe", "valeur": existant}, + {"type": "fichier_existe", "valeur": os.path.join(espace, "pas_la.py")}, + {"type": "commande", "valeur": "pytest"}, # non constatable : absent + ]) + assert etats == {0: True, 1: False} + + +# ── Le contrat restauré par la reprise sert de plancher ────────────────────── + + +def _dag_serialise() -> list[dict]: + return [{"task_objective": "Créer le livrable", "task_id": "t1", "depends_on": [], + "available_tools": ["write_file"], "relevant_context": "", "metadata": {}, + "status": "pending", "assigned_agent": None, "result_summary": None}] + + +class TestRepriseEtPlancher: + @pytest.mark.asyncio + async def test_le_contrat_restaure_est_le_plancher_de_la_reprise(self, espace, monkeypatch): + """La reprise rejoue le DAG approuvé : le contrat restauré depuis + `hitl_pending_approvals` doit être celui qui sert de plancher, et un + contrat correctif tautologique posé pendant le rejeu ne doit pas + l'évincer.""" + from core.engine import Engine + from services.approval_resume_service import reprendre_apres_approbation + + monkeypatch.delenv("MOTEUR_CONTRAT_SUR_ECHEC", raising=False) + + hitl_store.enregistrer_demande( + request_id="r_plancher", session_id="s_reprise", dag_tasks=_dag_serialise(), + description="", criteres=[_critere_livrable_absent(espace)], + ) + hitl_store.marquer_decision("r_plancher", approuve=True) + + captures: dict = {} + + def _faux_moteur(session_id, objectif, config): + engine = Engine(session_id=session_id) + bridge = MagicMock() + bridge.start = AsyncMock() + bridge.stop = AsyncMock() + captures["engine"] = engine + return engine, MagicMock(), bridge, MagicMock() + + monkeypatch.setattr("services.pipeline_service.monter_moteur", _faux_moteur) + monkeypatch.setattr( + "core.execution_budget.ExecutionBudget.from_config", + classmethod(lambda cls, session_id: MagicMock(max_tokens=1000)), + ) + + async def _faux_dag(self, dag_tasks, objectif, max_session_tokens, budget=None): + # Le rejeu échoue et un plan correctif pose un contrat tautologique + # sur un fichier préexistant — exactement la scène du 17/08. + preexistant = os.path.join(espace, "ancien_module.py") + open(preexistant, "w").close() + self.state.history.append(StateUpdate( + agent_name="planner", status="success", + metadata={ + CLE_CONTRAT: [{"type": "fichier_existe", "valeur": preexistant}], + CLE_ETAT_INITIAL: {0: True}, + }, + )) + captures["verdict_contrat"] = await self._review_loop.contrat_seul() + return {"t1": "error"}, True + + monkeypatch.setattr(Engine, "executer_dag_et_controles", _faux_dag) + + res = await reprendre_apres_approbation("r_plancher") + + engine = captures["engine"] + # 1. Le contrat restauré est en TÊTE d'historique : c'est le plancher. + restaure = engine.state.history[0] + assert restaure.metadata[CLE_CONTRAT] == [_critere_livrable_absent(espace)] + # Le livrable n'existait pas à la reprise : pas « satisfait d'avance ». + assert restaure.metadata[CLE_ETAT_INITIAL] == {0: False} + # 2. Le verdict du contrat est un ÉCHEC malgré le contrat tautologique. + assert captures["verdict_contrat"] is False + # 3. La reprise ne déclare pas un faux succès. + assert res["status"] == "error" + + @pytest.mark.asyncio + async def test_le_plancher_est_la_premiere_entree_meme_apres_plusieurs_correctifs(self): + engine = _EngineMock() + _poser_contrat(engine, [{"type": "commande", "valeur": "pytest"}]) + _poser_contrat(engine, [{"type": "commande", "valeur": "ruff check core/"}]) + _poser_contrat(engine, [{"type": "commande", "valeur": "mypy core/"}]) + + entrees = ReviewLoop(engine)._entrees_contrat() + assert entrees[0]["criteres"][0]["valeur"] == "pytest", ( + "le plancher doit rester le premier contrat posé, quoi qu'il arrive ensuite" + ) + + +# ── Arbitrage : substitution déclarée, jamais silencieuse ──────────────────── + + +class TestSubstitutionTracee: + def test_une_substitution_valide_remplace_le_critere_du_plancher(self, espace, monkeypatch, caplog): + """Le plan correctif a légitimement changé de chemin : il DÉCLARE la + substitution, elle est journalisée, et seul le remplaçant est évalué.""" + monkeypatch.delenv("MOTEUR_CONTRAT_SUR_ECHEC", raising=False) + nouveau_fichier = os.path.join(espace, "livrable_renomme.py") + open(nouveau_fichier, "w").close() + + engine = _EngineMock() + _poser_contrat(engine, [_critere_livrable_absent(espace)], etat_initial={0: False}) + _poser_contrat( + engine, + [{"type": "fichier_existe", "valeur": nouveau_fichier}], + etat_initial={0: False}, + substitutions=[{ + "origine": _critere_livrable_absent(espace), + "remplacant": {"type": "fichier_existe", "valeur": nouveau_fichier}, + "motif": "le correctif a choisi un autre nom de fichier", + }], + ) + + import logging + with caplog.at_level(logging.WARNING, logger="core.review_loop"): + verdict = asyncio.run(ReviewLoop(engine).contrat_seul()) + + assert verdict is True + assert any("SUBSTITUTION TRACÉE" in r.message for r in caplog.records) + + def test_une_substitution_sans_declaration_n_existe_pas(self, espace, monkeypatch): + """Contre-épreuve du 17/08 : un plan correctif qui pose simplement un + AUTRE critère sans le déclarer comme substitution ne remplace rien — + le critère du plancher reste évalué et fait échouer le contrat.""" + monkeypatch.delenv("MOTEUR_CONTRAT_SUR_ECHEC", raising=False) + autre_fichier = os.path.join(espace, "autre.py") + open(autre_fichier, "w").close() + + engine = _EngineMock() + _poser_contrat(engine, [_critere_livrable_absent(espace)], etat_initial={0: False}) + _poser_contrat(engine, [{"type": "fichier_existe", "valeur": autre_fichier}], + etat_initial={0: False}) # pas de déclaration de substitution + + assert asyncio.run(ReviewLoop(engine).contrat_seul()) is False + + def test_une_substitution_vers_un_critere_satisfait_d_avance_est_refusee(self, espace, monkeypatch): + """Le remplaçant était déjà vrai avant le travail : substitution + refusée, le critère du plancher reste évalué (et échoue).""" + monkeypatch.delenv("MOTEUR_CONTRAT_SUR_ECHEC", raising=False) + preexistant = os.path.join(espace, "deja_present.py") + open(preexistant, "w").close() + + engine = _EngineMock() + _poser_contrat(engine, [_critere_livrable_absent(espace)], etat_initial={0: False}) + _poser_contrat( + engine, + [{"type": "fichier_existe", "valeur": preexistant}], + etat_initial={0: True}, + substitutions=[{ + "origine": _critere_livrable_absent(espace), + "remplacant": {"type": "fichier_existe", "valeur": preexistant}, + }], + ) + + assert asyncio.run(ReviewLoop(engine).contrat_seul()) is False + + def test_substitution_hors_plancher_ignoree(self, espace): + """Seul un critère du contrat d'origine peut être substitué : une + déclaration qui cible autre chose est écartée.""" + engine = _EngineMock() + _poser_contrat(engine, [_critere_livrable_absent(espace)], etat_initial={0: False}) + fantome = {"type": "fichier_existe", "valeur": "inexistant_nulle_part.py"} + _poser_contrat( + engine, + [{"type": "commande", "valeur": "pytest"}], + substitutions=[{"origine": fantome, "remplacant": {"type": "commande", "valeur": "pytest"}}], + ) + + _criteres, _etats, substitutions = ReviewLoop(engine)._contrat_complet() + assert substitutions == [] + + +# ── Le Planner constate l'état initial à la pose du plan ──────────────────── + + +def _plan_minimal(criteres, substitutions=None): + reponse = { + "plan": [{"task_id": "t1", "objective": "Créer le livrable", + "target_agent": "executor", "model_tier": "leger", "depends_on": []}], + "criteres_acceptation": criteres, + } + if substitutions is not None: + reponse["substitutions"] = substitutions + return reponse + + +def _lancer_planner(monkeypatch, reponse, espace=None): + from agents.planner import PlannerAgent + from core.state import TaskPayload + + async def _generate_structured_async(system_prompt, user_prompt, schema, **kw): + return reponse + + provider = MagicMock() + provider.generate_structured_async = _generate_structured_async + gateway = MagicMock() + gateway.get_provider.return_value = provider + gateway.get_provider_for_tier.return_value = ("tier-fort", provider) + + planner = PlannerAgent(llm_gateway=gateway, provider_name="fort") + return asyncio.run(planner.invoke(TaskPayload( + task_objective="objectif", metadata={"session_id": "s_planner"}, + ))) + + +class TestPlannerConstateLEtatInitial: + def test_le_planner_joint_le_constat_d_avant_travail(self, espace, monkeypatch): + existant = os.path.join(espace, "present.py") + open(existant, "w").close() + absent = os.path.join(espace, "a_creer.py") + + update = _lancer_planner(monkeypatch, _plan_minimal([ + {"type": "fichier_existe", "valeur": existant}, + {"type": "fichier_existe", "valeur": absent}, + ])) + + assert update.status == "success" + assert update.metadata[CLE_ETAT_INITIAL] == {0: True, 1: False} + + def test_le_planner_transporte_les_substitutions_declarees(self, espace, monkeypatch): + declaration = { + "origine": _critere_livrable_absent(espace), + "remplacant": {"type": "fichier_existe", "valeur": os.path.join(espace, "ailleurs.py")}, + "motif": "changement de chemin décidé par la correction", + } + update = _lancer_planner( + monkeypatch, + _plan_minimal( + [{"type": "fichier_existe", "valeur": os.path.join(espace, "ailleurs.py")}], + substitutions=[declaration], + ), + ) + + assert update.metadata[CLE_SUBSTITUTIONS] == [declaration] + + def test_le_schema_du_planner_expose_les_substitutions(self): + """Le champ `substitutions` est déclaré au schéma : c'est le seul + canal par lequel un remplacement de critère peut advenir.""" + from agents.planner import PlannerAgent + from core.state import TaskPayload + + captures = {} + + async def _generate_structured_async(system_prompt, user_prompt, schema, **kw): + captures["schema"] = schema + return {"plan": [], "criteres_acceptation": []} + + provider = MagicMock() + provider.generate_structured_async = _generate_structured_async + gateway = MagicMock() + gateway.get_provider.return_value = provider + gateway.get_provider_for_tier.return_value = ("tier-fort", provider) + + planner = PlannerAgent(llm_gateway=gateway, provider_name="fort") + asyncio.run(planner.invoke(TaskPayload( + task_objective="objectif", metadata={"session_id": "s_schema"}, + ))) + + assert "substitutions" in captures["schema"]["properties"] + # La clé reste facultative : un plan qui ne substitue rien laisse []. + assert "substitutions" not in captures["schema"]["required"] + + +# ── Sémantique du rapport avec critères satisfaits d'avance ───────────────── + + +class TestRapportAvecEtatInitial: + def _res(self, satisfait, d_avance=False): + return ResultatCritere( + Critere("commande", "pytest", satisfait_d_avance=d_avance), satisfait, "détail", + ) + + def test_seul_critere_satisfait_d_avance_ne_tranche_pas(self): + rapport = RapportContrat([self._res(True, d_avance=True)]) + assert rapport.determinable is False + assert rapport.satisfait is False + + def test_une_vraie_preuve_suffit_meme_avec_un_critere_d_avance(self): + rapport = RapportContrat([self._res(True, d_avance=True), self._res(True)]) + assert rapport.determinable is True + assert rapport.satisfait is True + + def test_echec_satisfait_d_avance_bloque_comme_regression(self): + """Un critère vrai avant le lot qui échoue maintenant est une + régression : elle bloque le verdict, tout en restant identifiée comme + « satisfait d'avance » dans le rapport.""" + rapport = RapportContrat([self._res(False, d_avance=True), self._res(True)]) + assert rapport.satisfait is False + assert len(rapport.echecs_satisfaits_d_avance) == 1 + assert "satisfait d'avance" in rapport.resume() + + def test_un_echec_reel_invalide_meme_avec_des_criteres_d_avance(self): + rapport = RapportContrat([self._res(True, d_avance=True), self._res(False)]) + assert rapport.satisfait is False diff --git a/tests/unit/test_daemon_loop.py b/tests/unit/test_daemon_loop.py index 6935f07..061ff2f 100644 --- a/tests/unit/test_daemon_loop.py +++ b/tests/unit/test_daemon_loop.py @@ -42,7 +42,7 @@ def set_missing_host_key_policy(self, policy): pass def connect(self, **kwargs): - raise ConnectionRefusedError("Connexion refusée par 192.168.1.x") + raise ConnectionRefusedError("Connexion refusée par 192.168.1.10") class _FakeChannel: diff --git a/tests/unit/test_dag_watchdog_configurable.py b/tests/unit/test_dag_watchdog_configurable.py new file mode 100644 index 0000000..79894f8 --- /dev/null +++ b/tests/unit/test_dag_watchdog_configurable.py @@ -0,0 +1,212 @@ +""" +test_dag_watchdog_configurable.py — Watchdog asynchrone configurable (#T380). + +Vérifie que le délai du Circuit Breaker Asynchrone d'une tâche DAG n'est plus un +littéral unique de 120 s : +- le délai par défaut reste 120 s quand rien n'est configuré (non-régression) ; +- une tâche qui dépasse le délai de base mais reste sous le délai configuré pour + son tier n'est PAS tuée ; la même tâche avec la configuration par défaut l'est ; +- le message de journal du déclenchement contient la durée réelle, l'agent visé + et le délai appliqué (diagnostic) ; +- une tâche réellement bloquée est toujours interrompue (le watchdog n'est pas + supprimé ni mis à l'infini). + +Aucun appel réseau : `try_swarm_dispatch` est mocké par une simple coroutine qui +dort, et `load_config` renvoie un dict vide pour que seul l'environnement pilote +les délais (déterminisme, indépendant du config.json du poste). +""" + +import asyncio +import logging +import os +import sys +from unittest.mock import patch + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from core.dag_runner import ( + DEFAULT_DAG_TASK_TIMEOUT_S, + DAGRunner, + _delai_watchdog_seconds, +) +from core.state import GlobalState, StateUpdate, TaskPayload + + +class _Agent: + """Agent mocké : ne doit jamais être invoqué (le dispatch Swarm est mocké).""" + + def __init__(self, name="executor"): + self.name = name + + async def invoke(self, payload): + raise AssertionError("L'agent local ne doit pas être invoqué dans ces tests.") + + +class _EngineMock: + """Engine minimal pour instancier le DAGRunner et lancer _run_single_task.""" + + def __init__(self): + self.state = GlobalState(session_id="test_watchdog") + self._history_lock = asyncio.Lock() + self.context_manager = None + self.agents = {"executor": _Agent()} + + async def _validate_modified_yamls(self): + return None + + +@pytest.fixture(autouse=True) +def _config_vide_et_env_propre(monkeypatch): + """Charge une config vide et neutralise les variables d'environnement du watchdog.""" + monkeypatch.setattr("core.llm_gateway.load_config", lambda *a, **k: {}) + for cle in list(os.environ): + if cle.startswith(("MOTEUR_DAG_TASK_TIMEOUT_S", "MOTEUR_DAG_TASK_TIMEOUT_TIER_")): + monkeypatch.delenv(cle, raising=False) + + +def _tache(task_id="t1", tier="fort", timeout_payload=None): + """Fabrique une tâche DAG, tier par défaut 'fort' (le cas qui a perdu le 17/08).""" + meta = {"target_agent": "executor", "stage_id": 1, "model_tier": tier} + if timeout_payload is not None: + meta["watchdog_timeout_s"] = timeout_payload + return TaskPayload( + task_objective=f"objectif {task_id}", + task_id=task_id, + depends_on=[], + metadata=meta, + ) + + +async def _coroutine_qui_dort(duree_s): + """Remplace `try_swarm_dispatch` : dort, puis renvoie un succès.""" + + async def _fake(*args, **kwargs): + await asyncio.sleep(duree_s) + return StateUpdate( + agent_name="executor", + status="success", + result_data="ok", + metadata={}, + ) + + return _fake + + +class TestResolutionDelai: + """Résolution du délai par `_delai_watchdog_seconds`.""" + + def test_defaut_120_quand_rien_configure(self): + # Non-régression : rien configuré → 120 s, la valeur historique. + assert DEFAULT_DAG_TASK_TIMEOUT_S == 120.0 + assert _delai_watchdog_seconds(_tache(), config={}) == 120.0 + + def test_config_de_base_surcharge_le_defaut(self): + config = {"dag_task_timeout_s": 300.0} + assert _delai_watchdog_seconds(_tache(), config=config) == 300.0 + + def test_env_de_base_surcharge_la_config(self, monkeypatch): + monkeypatch.setenv("MOTEUR_DAG_TASK_TIMEOUT_S", "240") + config = {"dag_task_timeout_s": 300.0} + # L'env prime sur config.json. + assert _delai_watchdog_seconds(_tache(), config=config) == 240.0 + + def test_tier_fort_obtient_un_delai_plus_long_que_le_defaut(self): + # Le cœur du correctif : une tâche de tier fort peut dépasser 120 s sans + # être anormale (lecture de sources + synthèse LLM). + config = {"dag_task_timeout_by_tier": {"fort": 300.0}} + assert _delai_watchdog_seconds(_tache(tier="fort"), config=config) == 300.0 + + def test_tier_non_declare_retombe_sur_le_defaut(self): + config = {"dag_task_timeout_by_tier": {"fort": 300.0}} + # Tier 'moyen' non déclaré dans la table → délai de base (défaut 120). + assert _delai_watchdog_seconds(_tache(tier="moyen"), config=config) == 120.0 + + def test_env_par_tier_prime_sur_config_par_tier(self, monkeypatch): + monkeypatch.setenv("MOTEUR_DAG_TASK_TIMEOUT_TIER_FORT_S", "400") + config = {"dag_task_timeout_by_tier": {"fort": 300.0}} + assert _delai_watchdog_seconds(_tache(tier="fort"), config=config) == 400.0 + + def test_surcharge_payload_prioritaire(self, monkeypatch): + # Surcharge explicite de la tâche au-dessus de tout. + monkeypatch.setenv("MOTEUR_DAG_TASK_TIMEOUT_TIER_FORT_S", "400") + config = {"dag_task_timeout_by_tier": {"fort": 300.0}, "dag_task_timeout_s": 120.0} + assert _delai_watchdog_seconds(_tache(timeout_payload=500), config=config) == 500.0 + + def test_valeur_illisible_retombe_sur_le_defaut(self, monkeypatch): + monkeypatch.setenv("MOTEUR_DAG_TASK_TIMEOUT_S", "abc") + assert _delai_watchdog_seconds(_tache(), config={}) == 120.0 + + +class TestComportementWatchdog: + """Le vrai chemin `_run_single_task` respecte le délai configuré.""" + + async def _executer(self, runner, tache, fake_dispatch): + with patch("core.dag_runner.try_swarm_dispatch", fake_dispatch): + return await runner._run_single_task(tache.task_id, tache, on_event=None) + + async def test_tache_sous_son_delai_de_tier_n_est_pas_tuee(self, monkeypatch): + # Le test central : une tâche de tier fort qui dort 0,3 s dépasse le + # délai de base (0,1 s) mais reste sous le délai de son tier (0,5 s). + monkeypatch.setenv("MOTEUR_DAG_TASK_TIMEOUT_S", "0.1") + monkeypatch.setenv("MOTEUR_DAG_TASK_TIMEOUT_TIER_FORT_S", "0.5") + runner = DAGRunner(_EngineMock()) + + tache = _tache(tier="fort") + _, t_upd = await self._executer(runner, tache, await _coroutine_qui_dort(0.3)) + + assert t_upd.status == "success" + + async def test_meme_tache_avec_configuration_par_defaut_est_tuee(self, monkeypatch): + # La même tâche, sans surcharge de tier (seul le délai de base court + # s'applique) : 0,3 s > 0,1 s → le watchdog la tue. + monkeypatch.setenv("MOTEUR_DAG_TASK_TIMEOUT_S", "0.1") + runner = DAGRunner(_EngineMock()) + + tache = _tache(tier="fort") + _, t_upd = await self._executer(runner, tache, await _coroutine_qui_dort(0.3)) + + assert t_upd.status == "error" + assert "Timeout" in (t_upd.error_message or "") + assert "0.1" in (t_upd.error_message or "") + + async def test_message_de_journal_contient_duree_agent_et_delai(self, monkeypatch, caplog): + monkeypatch.setenv("MOTEUR_DAG_TASK_TIMEOUT_S", "0.1") + runner = DAGRunner(_EngineMock()) + + tache = _tache(task_id="t_diag", tier="fort") + + async def _bloquant(*a, **k): + await asyncio.sleep(30.0) + + with caplog.at_level(logging.ERROR, logger="core.dag_runner"): + await self._executer(runner, tache, _bloquant) + + # Le log de diagnostic doit nommer la tâche, l'agent visé et le délai. + assert any("t_diag" in r.message for r in caplog.records) + assert any("executor" in r.message for r in caplog.records) + assert any("0.1" in r.message for r in caplog.records) + # La durée réelle est un nombre (au moins un chiffre suivi de 's'). + assert any("s d'exécution" in r.message for r in caplog.records) + + async def test_tache_reellement_bloquee_est_toujours_interrompue(self, monkeypatch): + # Une tâche qui ne rend jamais la main est interrompue ~au délai, pas + # laissée tourner : le watchdog n'est ni supprimé ni mis à l'infini. + monkeypatch.setenv("MOTEUR_DAG_TASK_TIMEOUT_S", "0.2") + runner = DAGRunner(_EngineMock()) + + tache = _tache(tier="fort") + + async def _bloquant(*a, **k): + await asyncio.sleep(30.0) # beaucoup plus que le délai + + t0 = asyncio.get_event_loop().time() + with patch("core.dag_runner.try_swarm_dispatch", _bloquant): + _, t_upd = await runner._run_single_task(tache.task_id, tache, on_event=None) + ecoule = asyncio.get_event_loop().time() - t0 + + assert t_upd.status == "error" + assert "Timeout" in (t_upd.error_message or "") + # Interrompue bien avant les 30 s de la coroutine, ~au délai configuré. + assert ecoule < 5.0 diff --git a/tests/unit/test_dashscope_provider.py b/tests/unit/test_dashscope_provider.py index 1215f9a..1a5153d 100644 --- a/tests/unit/test_dashscope_provider.py +++ b/tests/unit/test_dashscope_provider.py @@ -132,18 +132,16 @@ def test_dashscope_models_db_registration(_catalogue_dashscope): assert sub["rolling_window_hours"] == 5 -def test_dashscope_gateway_binds_key(monkeypatch): - """Les alias dashscope/* doivent binder DASHSCOPE_API_KEY.""" +def test_dashscope_gateway_ne_instancie_plus(monkeypatch): + """D-8 : clé présente ≠ providers construits (401 + CGU backend).""" monkeypatch.setenv("DASHSCOPE_API_KEY", "sk-sp-test-key-for-unit") gateway = LLMGateway() - default = gateway.providers.get("dashscope") - coder = gateway.providers.get("dashscope/qwen3-coder-next") - assert default is not None - assert coder is not None - assert default.api_key == "sk-sp-test-key-for-unit" - assert coder.api_key == "sk-sp-test-key-for-unit" - assert coder.model == "qwen3-coder-next" - assert "coding" in coder.base_url + assert gateway.providers.get("dashscope") is None + assert gateway.providers.get("dashscope/qwen3-coder-next") is None + assert not any( + nom == "dashscope" or nom.startswith("dashscope/") + for nom in gateway.providers + ) @pytest.mark.live @@ -162,6 +160,11 @@ def test_dashscope_live_generation(): if not key: pytest.skip("Aucune clé DashScope configurée") + from core.llm_gateway import DASHSCOPE_ACTIF + + if not DASHSCOPE_ACTIF: + pytest.skip("Dashscope désactivé (D-8) — pas d'inférence live") + gateway = LLMGateway() provider = gateway.providers.get("dashscope/qwen3-coder-next") assert provider is not None, "dashscope/qwen3-coder-next doit être instancié" diff --git a/tests/unit/test_dreamcoder_fenetres_et_echecs.py b/tests/unit/test_dreamcoder_fenetres_et_echecs.py new file mode 100644 index 0000000..b0102ae --- /dev/null +++ b/tests/unit/test_dreamcoder_fenetres_et_echecs.py @@ -0,0 +1,163 @@ +""" +tests/unit/test_dreamcoder_fenetres_et_echecs.py — Budget de temps et comptage +des échecs d'une tâche autonome. + +Deux défauts mesurés en préparant la session autonome du 18/08 : + +1. `_process_one_dreamcoder_task` annonçait 10 minutes par tâche + (`wait_for(timeout=600)`) mais appelait `run_full_pipeline` SANS + `timeout_seconds` : le pipeline reprenait son défaut de 120 s, hérité du + chemin interactif. L'enveloppe était décorative — quatrième occurrence du + défaut que `core/fenetres_execution.py` existe pour empêcher. + +2. Une branche Git impossible à créer laissait la tâche en 'failed' avec + `retries` à 0 : jamais reprise (get_next_task ne lit que 'pending'), jamais + abandonnée. Constaté sur le Deck — deux tâches figées depuis juillet. +""" + +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from core.fenetres_execution import ( + MARGE_ENVELOPPE_DREAMCODER_S, + duree_enveloppe_dreamcoder_s, + duree_planner_s, + duree_tache_dreamcoder_s, + verifier_emboitement, +) + + +# ── La hiérarchie des fenêtres ─────────────────────────────────────────────── + +def test_fenetre_de_tache_par_defaut(): + """600 s : trois tâches tiennent dans un cycle de 30 min.""" + assert duree_tache_dreamcoder_s() == 600.0 + + +def test_fenetre_surchargeable_par_env(): + with patch.dict(os.environ, {"MOTEUR_DREAMCODER_TACHE_TIMEOUT_S": "300"}): + assert duree_tache_dreamcoder_s() == 300.0 + + +def test_valeur_illisible_retombe_sur_le_defaut(): + with patch.dict(os.environ, {"MOTEUR_DREAMCODER_TACHE_TIMEOUT_S": "dix minutes"}): + assert duree_tache_dreamcoder_s() == 600.0 + + +def test_enveloppe_strictement_au_dessus_de_la_tache(): + """ + L'enveloppe doit laisser le pipeline rendre son erreur de timeout et clore + sa session ; tuée trop tôt, elle laisserait une session zombie (#T361). + """ + assert duree_enveloppe_dreamcoder_s() > duree_tache_dreamcoder_s() + assert duree_enveloppe_dreamcoder_s() - duree_tache_dreamcoder_s() == MARGE_ENVELOPPE_DREAMCODER_S + + +def test_une_tache_depasse_son_propre_planner(): + """C'est exactement ce qui était faux : 120 s de pipeline pour un Planner plus long.""" + assert duree_tache_dreamcoder_s() > duree_planner_s() + assert verifier_emboitement() == [] + + +def test_emboitement_signale_une_fenetre_trop_courte(): + """Une tâche plus courte que son Planner doit être dénoncée, pas subie.""" + with patch.dict(os.environ, {"MOTEUR_DREAMCODER_TACHE_TIMEOUT_S": "10"}): + violations = verifier_emboitement() + + assert any("Tâche DreamCoder" in v for v in violations) + + +# ── Le câblage réel dans la tâche ──────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_le_pipeline_recoit_la_fenetre_de_nuit(): + """ + Le bug était l'ABSENCE du paramètre : le pipeline retombait sur 120 s sans + que rien ne le signale. Ce test échoue si quelqu'un le retire à nouveau. + """ + from agents import dreamer_agent + + tache = {"id": 77, "title": "Tâche pilote", "description": "faire un truc", "retries": 0} + bg = MagicMock() + bg.get_scoped_spend_usd = AsyncMock(return_value=0.0) + bg.record_usage = AsyncMock() + bg.config = {"total_daily_budget_usd": 1.0} + + routeur = MagicMock() + routeur.analyze_request = AsyncMock(return_value=({}, "planner")) + app_state = MagicMock() + app_state.get_shared_router.return_value = routeur + + pipeline = AsyncMock(return_value={"status": "completed", "response": "fait"}) + + with patch.object(dreamer_agent, "_dreamcoder_repo_root", return_value="/travail"), \ + patch("core.backlog_db.update_task_status", new=AsyncMock()), \ + patch("core.llm_gateway.load_config", return_value={}), \ + patch("core.app_state.get_app_state", return_value=app_state), \ + patch("tools.git_safety.git_prepare_agent_branch", return_value="task/77_1787000000"), \ + patch("tools.git_safety.git_generate_semantic_commit_msg", return_value="feat: x"), \ + patch("tools.git_safety._run_git", return_value=(0, "", "")), \ + patch("tools.git_safety.git_rollback_checkpoint", return_value=None), \ + patch.object(dreamer_agent, "_pousser_branche_tache", + return_value={"pushed": True, "raison": "ok", "detail": ""}), \ + patch("services.pipeline_service.run_full_pipeline", new=pipeline), \ + patch("core.token_tracker.get_session_total_tokens", return_value=42), \ + patch("core.budget_guard.mark_provider_failed"): + await dreamer_agent._process_one_dreamcoder_task( + tache, {}, "deepseek-free", "master", bg) + + assert pipeline.await_count == 1 + assert pipeline.await_args.kwargs["timeout_seconds"] == duree_tache_dreamcoder_s() + + +# ── Une branche impossible à créer est un échec qui se compte ──────────────── + +@pytest.mark.asyncio +async def test_echec_de_branche_incremente_les_tentatives(): + """ + Sans incrément, la tâche sort de 'pending' pour toujours : ni reprise, ni + abandon. C'est l'état dans lequel deux tâches du Deck étaient figées. + """ + from agents import dreamer_agent + + tache = {"id": 3, "title": "T", "description": "d", "retries": 0} + bg = MagicMock() + bg.get_scoped_spend_usd = AsyncMock(return_value=0.0) + bg.config = {"total_daily_budget_usd": 1.0} + maj = AsyncMock() + + with patch.object(dreamer_agent, "_dreamcoder_repo_root", return_value="/travail"), \ + patch("core.backlog_db.update_task_status", new=maj), \ + patch("core.llm_gateway.load_config", return_value={}), \ + patch("tools.git_safety.git_prepare_agent_branch", + return_value="Erreur : dépôt de travail = worktree Git lié."): + res = await dreamer_agent._process_one_dreamcoder_task( + tache, {}, "deepseek-free", "master", bg) + + assert res["status"] == "failed" + assert maj.await_args.kwargs["retries"] == 1 + + +@pytest.mark.asyncio +async def test_troisieme_echec_de_branche_abandonne(): + """Au 3ᵉ essai, la tâche est abandonnée — elle ne boucle pas chaque nuit.""" + from agents import dreamer_agent + + tache = {"id": 3, "title": "T", "description": "d", "retries": 2} + bg = MagicMock() + bg.get_scoped_spend_usd = AsyncMock(return_value=0.0) + bg.config = {"total_daily_budget_usd": 1.0} + maj = AsyncMock() + + with patch.object(dreamer_agent, "_dreamcoder_repo_root", return_value="/travail"), \ + patch("core.backlog_db.update_task_status", new=maj), \ + patch("core.llm_gateway.load_config", return_value={}), \ + patch("tools.git_safety.git_prepare_agent_branch", + return_value="Erreur : dépôt de travail = worktree Git lié."): + res = await dreamer_agent._process_one_dreamcoder_task( + tache, {}, "deepseek-free", "master", bg) + + assert res["status"] == "abandoned" + assert maj.await_args.args[1] == "abandoned" diff --git a/tests/unit/test_dreamcoder_push_branche.py b/tests/unit/test_dreamcoder_push_branche.py new file mode 100644 index 0000000..3f62bbc --- /dev/null +++ b/tests/unit/test_dreamcoder_push_branche.py @@ -0,0 +1,190 @@ +""" +tests/unit/test_dreamcoder_push_branche.py — Sortie du travail DreamCoder. + +Une tâche réussie produisait un commit qui ne quittait jamais le Deck. Le push +de la branche 'task/*' est désormais tenté à chaque succès, mais en best effort : +le Deck refuse le push de son dépôt de PROD (`no-push://…`, garde-fou du 07/08) +et sa clé de déploiement GitHub est en lecture seule. Ces tests figent le +contrat : on pousse quand on peut, on rend une raison lisible quand on ne peut +pas, et on ne casse JAMAIS une tâche réussie sur un problème de transport. +""" + +import subprocess +from unittest.mock import patch + +import pytest + +from agents.dreamer_agent import _pousser_branche_tache + + +def _git(args, cwd): + """Exécute un git de préparation de fixture (échoue bruyamment si KO).""" + subprocess.run(["git"] + args, cwd=cwd, check=True, + capture_output=True, text=True) + + +class _AiguillagePush: + """ + Intercepte UNIQUEMENT `git push`, laisse passer le reste. + + `agents.dreamer_agent.subprocess` EST le module subprocess : patcher son + `run` patcherait aussi celui de `tools.git_safety`, donc la lecture du + remote par `_run_git` — et le test ne vérifierait plus le vrai chemin. + """ + + def __init__(self, reponse=None, exception=None): + self._reponse, self._exception = reponse, exception + self._vrai_run = subprocess.run + self.appels_push = [] + + def __call__(self, args, *a, **kw): + if isinstance(args, list) and "push" in args: + self.appels_push.append({"args": args, "kwargs": kw}) + if self._exception is not None: + raise self._exception + return self._reponse + return self._vrai_run(args, *a, **kw) + + +@pytest.fixture +def depot_avec_origin(tmp_path): + """ + Un vrai clone relié à un vrai `origin` (dépôt bare local), avec une branche + de tâche portant un commit. Permet de vérifier le push pour de bon, sans + réseau ni mock du transport. + """ + bare = tmp_path / "origin.git" + _git(["init", "--bare", "-b", "master", str(bare)], cwd=str(tmp_path)) + + clone = tmp_path / "clone" + _git(["clone", str(bare), str(clone)], cwd=str(tmp_path)) + _git(["config", "user.email", "test@local"], cwd=str(clone)) + _git(["config", "user.name", "Test"], cwd=str(clone)) + + (clone / "fichier.txt").write_text("base", encoding="utf-8") + _git(["add", "-A"], cwd=str(clone)) + _git(["commit", "-m", "base"], cwd=str(clone)) + _git(["push", "origin", "master"], cwd=str(clone)) + + _git(["checkout", "-b", "task/42_1787000000"], cwd=str(clone)) + (clone / "fichier.txt").write_text("travail de l'agent", encoding="utf-8") + _git(["add", "-A"], cwd=str(clone)) + _git(["commit", "-m", "feat: travail de la tache 42"], cwd=str(clone)) + + return {"clone": str(clone), "bare": str(bare), "branche": "task/42_1787000000"} + + +# ── Le chemin qui marche : la branche sort vraiment ────────────────────────── + +def test_push_reel_publie_la_branche_sur_origin(depot_avec_origin): + """Un push qui aboutit crée bien la ref côté origin — vérifié, pas supposé.""" + res = _pousser_branche_tache(depot_avec_origin["clone"], depot_avec_origin["branche"]) + + assert res["pushed"] is True + assert res["raison"] == "ok" + + refs = subprocess.run(["git", "branch", "--list"], cwd=depot_avec_origin["bare"], + capture_output=True, text=True).stdout + assert depot_avec_origin["branche"] in refs + + +def test_push_pose_le_suivi_amont(depot_avec_origin): + """--set-upstream : la branche récoltée sait d'où elle vient.""" + _pousser_branche_tache(depot_avec_origin["clone"], depot_avec_origin["branche"]) + + amont = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + cwd=depot_avec_origin["clone"], capture_output=True, text=True).stdout.strip() + assert amont == f"origin/{depot_avec_origin['branche']}" + + +# ── Les refus : une raison lisible, jamais une exception ───────────────────── + +def test_remote_no_push_du_deck_est_reconnu_sans_tenter(depot_avec_origin): + """ + Le dépôt de prod du Deck porte `no-push://le-deck-est-une-cible-de-deploiement`. + On veut une raison nommée ET aucune tentative réseau derrière. + """ + _git(["remote", "set-url", "--push", "origin", + "no-push://le-deck-est-une-cible-de-deploiement"], cwd=depot_avec_origin["clone"]) + + aiguillage = _AiguillagePush(reponse=subprocess.CompletedProcess([], 0, "", "")) + with patch("agents.dreamer_agent.subprocess.run", aiguillage): + res = _pousser_branche_tache(depot_avec_origin["clone"], depot_avec_origin["branche"]) + + assert res["pushed"] is False + assert res["raison"] == "remote_no_push" + assert aiguillage.appels_push == [] # aucune tentative réseau derrière + + +def test_remote_absent(tmp_path): + """Un dépôt sans origin ne doit pas partir en exception.""" + depot = tmp_path / "sans_origin" + depot.mkdir() + _git(["init", "-b", "master", "."], cwd=str(depot)) + + res = _pousser_branche_tache(str(depot), "task/1_1787000000") + assert res["pushed"] is False + assert res["raison"] == "remote_absent" + + +def test_push_refuse_rend_la_raison_du_serveur(depot_avec_origin): + """ + Cas réel attendu sur le Deck : clé de déploiement en lecture seule. + Le message du serveur doit remonter (tronqué) pour être diagnosticable. + """ + echec = subprocess.CompletedProcess( + args=[], returncode=128, stdout="", + stderr="ERROR: The key you are authenticating with has not been granted write access.") + + with patch("agents.dreamer_agent.subprocess.run", _AiguillagePush(reponse=echec)): + res = _pousser_branche_tache(depot_avec_origin["clone"], depot_avec_origin["branche"]) + + assert res["pushed"] is False + assert res["raison"] == "refuse" + assert "write access" in res["detail"] + + +def test_timeout_ne_mange_pas_la_fenetre_du_cycle(depot_avec_origin): + """ + Le push vit dans le budget `dreamcoder_max_cycle_minutes`, partagé par + toutes les tâches : un push qui pend doit être coupé, pas attendu. + """ + expiration = subprocess.TimeoutExpired(cmd="git push", timeout=90.0) + with patch("agents.dreamer_agent.subprocess.run", _AiguillagePush(exception=expiration)): + res = _pousser_branche_tache(depot_avec_origin["clone"], depot_avec_origin["branche"]) + + assert res["pushed"] is False + assert res["raison"] == "timeout" + + +def test_erreur_inattendue_est_absorbee(depot_avec_origin): + """Aucune exception ne remonte : une tâche réussie le reste.""" + with patch("agents.dreamer_agent.subprocess.run", + _AiguillagePush(exception=OSError("git introuvable"))): + res = _pousser_branche_tache(depot_avec_origin["clone"], depot_avec_origin["branche"]) + + assert res["pushed"] is False + assert res["raison"] == "erreur" + + +# ── Le garde-fou qui évite le service figé ─────────────────────────────────── + +def test_git_terminal_prompt_desactive(depot_avec_origin): + """ + Le clone dédié du Deck a un `origin` HTTPS sans identifiants. Sans + GIT_TERMINAL_PROMPT=0, git attend une saisie qui ne viendra jamais dans un + service systemd — et le cycle entier reste bloqué là. + """ + ok = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") + aiguillage = _AiguillagePush(reponse=ok) + + with patch("agents.dreamer_agent.subprocess.run", aiguillage): + _pousser_branche_tache(depot_avec_origin["clone"], depot_avec_origin["branche"]) + + assert len(aiguillage.appels_push) == 1 + appel = aiguillage.appels_push[0] + assert appel["kwargs"]["env"]["GIT_TERMINAL_PROMPT"] == "0" + assert appel["kwargs"]["timeout"] == 90.0 + assert appel["args"] == [ + "git", "push", "--set-upstream", "origin", depot_avec_origin["branche"]] diff --git a/tests/unit/test_event_store_isolation_t348.py b/tests/unit/test_event_store_isolation_t348.py new file mode 100644 index 0000000..ea532bb --- /dev/null +++ b/tests/unit/test_event_store_isolation_t348.py @@ -0,0 +1,119 @@ +""" +tests/unit/test_event_store_isolation_t348.py — l'EventStore ne touche plus la +base réelle du dépôt (#T348). + +#T348 : `core/event_store.py` calculait son chemin par défaut indépendamment de +`core.runtime_db` (`DEFAULT_DB` figé à l'import), et le singleton +`get_event_store()` figeait l'instance au premier appel du process. Un test qui +appelait `override_db_path()` croyait être isolé, pendant que l'EventStore +continuait d'écrire dans la vraie base du dépôt (moteur_runtime.db). La fixture +autouse `_isoler_base_runtime` (tests/conftest.py) réoriente désormais la base +pour CHAQUE test ; ces tests vérifient que l'EventStore la respecte. + +Les tests : +1. LE TEST CENTRAL : `get_event_store().log(...)` sans aucune isolation locale + (ni `override_db_path`, ni `tmp_path` pour la base) n'ajoute PAS de ligne au + fichier `moteur_runtime.db` de la racine du dépôt. Échoue sur master. +2. Le singleton suit un changement de chemin : deux `get_event_store()` + séparés par un `override_db_path()` n'écrivent pas dans le même fichier. + +Le second critère d'acceptation (#T348) — le compte de lignes de `events` dans +la base du dépôt est identique avant/après un `pytest tests/unit -q` complet — +est vérifié à la main (commande de reproduction), pas par un test unitaire. +""" +import asyncio +import sqlite3 +from pathlib import Path + +from core import runtime_db + + +def _compter_events(fichier: str) -> int: + """Compte les lignes de la table `events` d'un fichier SQLite donné.""" + if not Path(fichier).exists(): + return 0 + conn = sqlite3.connect(fichier) + try: + # Sur CI le fichier dépôt peut exister (créé par un autre module) sans + # table `events` — ce n'est pas une écriture EventStore. + row = conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='events'" + ).fetchone() + if row is None: + return 0 + return conn.execute("SELECT COUNT(*) FROM events").fetchone()[0] + finally: + conn.close() + + +def test_l_event_store_n_ecrit_pas_dans_la_base_du_depot(): + """LE TEST CENTRAL #T348. + + Ce test n'isole RIEN lui-même : il ne connaît ni `override_db_path`, ni un + `tmp_path` pour la base — il se repose entièrement sur la fixture autouse + `_isoler_base_runtime`. Il appelle `get_event_store().log(...)` puis vérifie + que le fichier `moteur_runtime.db` à la racine du dépôt n'a pas gagné une + ligne. + + Sur master (avant #T348), ce test ÉCHOUE : l'EventStore écrivait dans la + vraie base du dépôt. + """ + from core.event_store import get_event_store + + racine = Path(__file__).resolve().parent.parent.parent + fichier_depot = str(racine / "moteur_runtime.db") + + avant = _compter_events(fichier_depot) + + async def _ecrire(): + es = get_event_store() + await es.log("request_received", source="test_t348", payload={"prompt": "bonjour"}) + + asyncio.run(_ecrire()) + + apres = _compter_events(fichier_depot) + + assert apres == avant, ( + f"l'EventStore a écrit dans la base du dépôt : {avant} → {apres}. " + "La fixture autouse _isoler_base_runtime n'a pas suffi — vérifie " + "core/event_store.py (chemin résolu à l'appel + singleton réinitialisé)." + ) + + +def test_le_singleton_suit_un_changement_de_chemin(): + """Le singleton se réinitialise quand le chemin change (#T348). + + Deux `get_event_store()` séparés par un `override_db_path()` n'écrivent pas + dans le même fichier : le second suit le nouveau chemin. C'est le garde-fou + qui interdit de traiter #T348 en ajoutant seulement `override_db_path()` + dans `test_fast_path_agent_trace.py`. + """ + from core.event_store import get_event_store + + # Chemin A : celui posé par la fixture autouse pour CE test. + chemin_a = runtime_db.get_db_path() + + async def _ecrire(): + es = get_event_store() + await es.log("request_received", source="test_t348_a", payload={}) + return es.db_path + + chemin_utilise_a = asyncio.run(_ecrire()) + assert chemin_utilise_a == chemin_a + + # Réorienter vers un autre fichier, puis réécrire : le singleton doit suivre. + chemin_b = str(Path(chemin_a).with_name("moteur_runtime_iso_b.db")) + runtime_db.override_db_path(chemin_b) + + async def _ecrire_b(): + es_b = get_event_store() + await es_b.log("request_received", source="test_t348_b", payload={}) + return es_b.db_path + + chemin_utilise_b = asyncio.run(_ecrire_b()) + + assert chemin_utilise_b == chemin_b, ( + f"le singleton n'a pas suivi le changement de chemin : " + f"il écrit encore dans {chemin_utilise_b!r} au lieu de {chemin_b!r}." + ) + assert chemin_utilise_a != chemin_utilise_b diff --git a/tests/unit/test_execute_chemins_legers_hors_creneau_t344.py b/tests/unit/test_execute_chemins_legers_hors_creneau_t344.py new file mode 100644 index 0000000..e0479cc --- /dev/null +++ b/tests/unit/test_execute_chemins_legers_hors_creneau_t344.py @@ -0,0 +1,271 @@ +""" +tests/unit/test_execute_chemins_legers_hors_creneau_t344.py — les chemins +légers d'`/api/execute` sortent du créneau d'exécution (#T344). + +#T324 a remplacé le verrou global par un registre PAR SESSION avec plafond de +concurrence (`MOTEUR_EXECUTION_MAX_CONCURRENCY`, défaut 3). #T304 avait déjà +sorti le mode Discussion du portillon. Mais le portillon restait posé AVANT le +choix du chemin : les cinq chemins légers restants (casual_chat, HA déterministe, +fuzzy HA, repli LLM, small-talk) tenaient un créneau du plafond pendant toute +leur durée, alors qu'ils ne touchent JAMAIS l'Engine partagé que ce portillon +protège. Conséquence : trois requêtes légères en vol suffisaient à refuser un +vrai DAG de l'IHM. + +#T344 descend la prise du créneau (`begin_execution`) juste avant +`run_full_pipeline`, son seul consommateur réel, et introduit un COMPTEUR DE +REQUÊTES EN VOL (`active_requests`) distinct du registre de concurrence pour +maintenir la contre-pression du dreamer : tant qu'une requête tourne — chemin +léger compris — `execution_state["status"]` reste "running" et le dreamer se +tait (il lit cette vue pour savoir si l'utilisateur travaille). + +Les tests pilotent la VRAIE route `execute_chat` ; seuls le routeur (analyse) et +le travail LLM (fast path) sont doublés. Aucun réseau, aucun LLM. +""" +import asyncio +import gc +import os +import shutil +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException + +from api.routes import agents as route_agents +from core.app_state import _cle_execution, get_app_state +from core.runtime_db import get_connection, override_db_path +from core.source_router import parse_source + + +@pytest.fixture +def temp_runtime_db(): + """Base jetable : `log_vocal_request/response` écrivent réellement.""" + tmp = tempfile.mkdtemp() + db_path = os.path.join(tmp, "test_runtime.db") + override_db_path(db_path) + conn = get_connection() + # DELETE évite les fichiers WAL verrouillés sous Windows en teardown + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + conn.close() + yield db_path + gc.collect() + default_db = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "moteur_runtime.db" + ) + override_db_path(default_db) + shutil.rmtree(tmp, ignore_errors=True) + + +@pytest.fixture +def etat_moteur(): + """Registre, vue agrégée et compteur de requêtes remis au repos autour du test.""" + state = get_app_state() + registre_avant = dict(state.execution_registry) + vue_avant = dict(state.execution_state) + compteur_avant = state.active_requests + state.execution_registry.clear() + state.active_requests = 0 + state.execution_state.update({ + "status": "idle", "objective": "", "engine_state": None, + "error_message": None, + }) + yield state + state.execution_registry.clear() + state.execution_registry.update(registre_avant) + state.active_requests = compteur_avant + state.execution_state.clear() + state.execution_state.update(vue_avant) + + +def _corps(prompt="raconte une blague", **source_extra): + """Corps de requête sur un chemin LÉGER (mode default, hors HA/CHAT).""" + source = {"type": "ide", "mode": "default", "tts_enabled": False} + source.update(source_extra) + return route_agents.ExecuteRequestBody(user_prompt=prompt, source=source) + + +def _corps_lourd(prompt="refactore le module de routage", session_id="session_ide"): + """Corps de requête qui va au pipeline complet (mode default).""" + return route_agents.ExecuteRequestBody( + user_prompt=prompt, + source={"type": "ide", "mode": "default", "tts_enabled": False}, + session_id=session_id, + ) + + +def _occuper(state, cle: str, objectif="un DAG en cours") -> None: + """Simule une exécution lourde en vol pour la clé donnée.""" + state.execution_registry[cle] = { + "status": "running", "objective": objectif, + "engine_state": None, "error_message": None, + } + state.execution_state.update({"status": "running", "objective": objectif}) + + +def _routeur_casual_chat(monkeypatch): + """Routeur doublé : analyse qui route vers le fast path casual_chat.""" + payload = SimpleNamespace(metadata={"routing_type": "casual_chat"}) + faux_routeur = MagicMock() + faux_routeur.analyze_request = AsyncMock(return_value=(payload, "fast_path")) + monkeypatch.setattr("core.app_state.AppState.get_shared_router", + MagicMock(return_value=faux_routeur)) + return faux_routeur + + +def _routeur_pipeline(monkeypatch): + """Routeur doublé : analyse qui route vers le pipeline complet (lourd).""" + payload = SimpleNamespace(metadata={"routing_type": "default"}) + faux_routeur = MagicMock() + faux_routeur.analyze_request = AsyncMock(return_value=(payload, "planner")) + monkeypatch.setattr("core.app_state.AppState.get_shared_router", + MagicMock(return_value=faux_routeur)) + return faux_routeur + + +# ── TEST CENTRAL : plafond saturé, un chemin léger répond quand même ───────── + +@pytest.mark.asyncio +async def test_chemin_leger_repond_meme_avec_le_plafond_sature( + temp_runtime_db, etat_moteur, monkeypatch +): + """Le plafond de concurrence saturé par trois exécutions lourdes : une + requête qui emprunte un chemin LÉGER répond quand même. + + Échouait sur master : le portillon était posé AVANT le choix du chemin, donc + la requête légère prenait un créneau et se voyait refuser (409 plafond). + """ + _routeur_casual_chat(monkeypatch) + monkeypatch.setattr(route_agents, "run_fast_path", + AsyncMock(return_value="blague vite faite")) + monkeypatch.setattr(route_agents, "LLMGateway", MagicMock()) + + # Plafond saturé par des exécutions lourdes en vol. + for i in range(etat_moteur.max_concurrency): + _occuper(etat_moteur, f"session_lourde_{i}") + + result = await route_agents.execute_chat(_corps()) + + assert result["status"] == "completed", result + assert result["response"] == "blague vite faite" + # Aucun créneau consommé : le registre n'a reçu aucune entrée nouvelle. + assert list(etat_moteur.execution_registry) == [ + f"session_lourde_{i}" for i in range(etat_moteur.max_concurrency) + ] + + +# ── TEST GARDE-FOU : le dreamer voit toujours l'activité utilisateur ───────── + +@pytest.mark.asyncio +async def test_chemin_leger_maintient_le_signal_d_activite_pour_le_dreamer( + temp_runtime_db, etat_moteur, monkeypatch +): + """Pendant qu'une requête légère est en vol, le signal de contre-pression lu + par le dreamer (`execution_state["status"] == "running"`) indique bien une + activité utilisateur. Sans ce test, la PR est refusée même si le premier + passe : si les chemins légers ne déclarent plus rien, le dreamer se réveille + pendant qu'Axel parle à son Tab5. + """ + _routeur_casual_chat(monkeypatch) + monkeypatch.setattr(route_agents, "LLMGateway", MagicMock()) + + # Le fast path reste bloqué tant que `porte` n'est pas posée : on observe la + # requête légère en vol, exactement comme le dreamer la verrait. `entree` + # signale que le fast path (donc le début du travail léger) est atteint. + entree = asyncio.Event() + porte = asyncio.Event() + + async def _fast_path_bloque(*args, **kwargs): + entree.set() + await porte.wait() + return "blague vite faite" + + monkeypatch.setattr(route_agents, "run_fast_path", _fast_path_bloque) + + # Lance la requête légère en tâche de fond pour l'observer en vol. + tache = asyncio.create_task(route_agents.execute_chat(_corps())) + await asyncio.wait_for(entree.wait(), timeout=5) + + # Pendant qu'elle tourne : le dreamer doit voir l'utilisateur actif. + assert etat_moteur.execution_state["status"] == "running", ( + "le dreamer ne verrait aucune activité pendant la requête légère" + ) + assert etat_moteur.active_requests >= 1 + + porte.set() + await tache + # Après la requête : plus rien en vol → la vue retombe au repos. + assert etat_moteur.active_requests == 0 + assert etat_moteur.execution_state["status"] == "idle" + + +# ── TEST DOUBLON LOURD : le portillon protège toujours ce qu'il protège ────── + +@pytest.mark.asyncio +async def test_deux_requetes_lourdes_de_la_meme_session_409_doublon( + temp_runtime_db, etat_moteur, monkeypatch +): + """Deux requêtes lourdes de la MÊME clé de session → toujours 409 doublon. + Le portillon descendu juste avant le pipeline doit continuer à protéger + l'Engine partagé : une même session ne s'exécute jamais deux fois.""" + _routeur_pipeline(monkeypatch) + monkeypatch.setattr(route_agents, "run_full_pipeline", + AsyncMock(return_value={"status": "success", "response": "ok"})) + monkeypatch.setattr(route_agents, "LLMGateway", MagicMock()) + + corps = _corps_lourd(session_id="session_ide") + cle_ide = _cle_execution("session_ide", parse_source(corps.source)) + _occuper(etat_moteur, cle_ide, objectif="premier DAG") + + with pytest.raises(HTTPException) as exc: + await route_agents.execute_chat(corps) + + assert exc.value.status_code == 409 + assert "déjà en cours" in exc.value.detail or "Doublon" in exc.value.detail or "doublon" in exc.value.detail + + +# ── TEST ORPHELINE : aucune entrée laissée, même sur exception ─────────────── + +@pytest.mark.asyncio +async def test_pas_d_entree_orpheline_apres_un_chemin_leger( + temp_runtime_db, etat_moteur, monkeypatch +): + """Une requête légère qui se termine ne laisse AUCUNE entrée dans + `execution_registry` (elle n'en a jamais créé) et remet le compteur à zéro.""" + _routeur_casual_chat(monkeypatch) + monkeypatch.setattr(route_agents, "run_fast_path", + AsyncMock(return_value="blague vite faite")) + monkeypatch.setattr(route_agents, "LLMGateway", MagicMock()) + + await route_agents.execute_chat(_corps()) + + assert etat_moteur.execution_registry == {} + assert etat_moteur.active_requests == 0 + + +@pytest.mark.asyncio +async def test_pas_d_entree_orpheline_apres_une_exception_dans_le_chemin_leger( + temp_runtime_db, etat_moteur, monkeypatch +): + """Une exception levée DANS un chemin léger (small-talk HA) ne laisse aucune + entrée orpheline dans le registre et remet le compteur à zéro — le finally + libère toujours le compteur, et ne libère jamais une entrée non créée.""" + # Route vers le chemin small-talk HA (routing "default", mode HA), en + # neutralisant le fuzzy matcher pour que la requête atteigne bien ce palier. + _routeur_pipeline(monkeypatch) + monkeypatch.setattr("core.ha_fuzzy_matcher.get_fuzzy_matcher", lambda: None) + monkeypatch.setattr( + route_agents, "_ha_conversational_response", + MagicMock(side_effect=RuntimeError("panne small-talk")), + ) + + corps = route_agents.ExecuteRequestBody( + user_prompt="raconte une blague", + source={"type": "tab5", "mode": "ha", "tts_enabled": True}, + ) + result = await route_agents.execute_chat(corps) + + assert result["status"] == "error", result # repli d'erreur générique + assert etat_moteur.execution_registry == {} + assert etat_moteur.active_requests == 0 diff --git a/tests/unit/test_execute_discussion_hors_creneau_t324.py b/tests/unit/test_execute_discussion_hors_creneau_t324.py new file mode 100644 index 0000000..f50c7eb --- /dev/null +++ b/tests/unit/test_execute_discussion_hors_creneau_t324.py @@ -0,0 +1,291 @@ +""" +tests/unit/test_execute_discussion_hors_creneau_t324.py — le mode Discussion +sort du registre d'exécution (suite de #T324). + +#T324 a remplacé le verrou global par un verrou PAR SESSION avec plafond de +concurrence (`MOTEUR_EXECUTION_MAX_CONCURRENCY`, défaut 3). Le portillon reste +posé AVANT le choix du chemin, or la branche Discussion (`mode=chat`) ne touche +pas l'Engine partagé que ce portillon protège. Elle payait donc deux fois : + + - un créneau du plafond pendant toute sa durée — trois tours vocaux en vol + suffisaient à refuser un DAG de l'IHM ; + - un 409 « doublon de session » sur un second tour vocal, la clé d'exécution + du Tab5 retombant sur son `device_id` faute de `session_id`. + +Et `router.analyze_request()` était appelé puis JETÉ (le `routing_type` retenu +vient de `host_result`). + +Les tests pilotent la VRAIE route `execute_chat` ; seuls le travail LLM +(`handle_discussion`) et la passerelle sont doublés. Aucun réseau, aucun LLM. +""" +import gc +import os +import shutil +import tempfile +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException + +from api.routes import agents as route_agents +from core.app_state import _cle_execution, get_app_state +from core.runtime_db import get_connection, override_db_path +from core.source_router import parse_source + + +@pytest.fixture +def temp_runtime_db(): + """Base jetable : `log_vocal_request/response` écrivent réellement.""" + tmp = tempfile.mkdtemp() + db_path = os.path.join(tmp, "test_runtime.db") + override_db_path(db_path) + conn = get_connection() + # DELETE évite les fichiers WAL verrouillés sous Windows en teardown + conn.execute("PRAGMA journal_mode=DELETE") + conn.commit() + conn.close() + yield db_path + gc.collect() + default_db = os.path.join( + os.path.dirname(os.path.dirname(__file__)), "moteur_runtime.db" + ) + override_db_path(default_db) + shutil.rmtree(tmp, ignore_errors=True) + + +@pytest.fixture +def etat_moteur(): + """Registre d'exécution et vue agrégée remis au repos autour du test.""" + state = get_app_state() + registre_avant = dict(state.execution_registry) + vue_avant = dict(state.execution_state) + state.execution_registry.clear() + state.execution_state.update({ + "status": "idle", "objective": "", "engine_state": None, + "error_message": None, + }) + yield state + state.execution_registry.clear() + state.execution_registry.update(registre_avant) + state.execution_state.clear() + state.execution_state.update(vue_avant) + + +@pytest.fixture +def discussion(monkeypatch): + """Double de `handle_discussion` : résultat plausible, appels comptés.""" + host_result = SimpleNamespace( + response_text="Il fait 21 degrés dans le salon.", + async_job_id=None, + agents_used=["vocal_host"], + routing_type="discussion_chat", + metadata={"modele": "faux-modele"}, + ) + faux = AsyncMock(return_value=host_result) + monkeypatch.setattr("core.vocal_host.handle_discussion", faux) + monkeypatch.setattr(route_agents, "LLMGateway", MagicMock()) + return faux + + +def _corps(prompt="il fait combien dans le salon ?", **source_extra): + source = {"type": "tab5", "mode": "chat", "tts_enabled": True} + source.update(source_extra) + return route_agents.ExecuteRequestBody(user_prompt=prompt, source=source) + + +def _occuper(state, cle: str, objectif="un DAG en cours") -> None: + """Simule une exécution en vol pour la clé donnée.""" + state.execution_registry[cle] = { + "status": "running", "objective": objectif, + "engine_state": None, "error_message": None, + } + state.execution_state.update({"status": "running", "objective": objectif}) + + +# ── Le plafond de concurrence ne doit pas museler le vocal ─────────────────── + +@pytest.mark.asyncio +async def test_discussion_repond_meme_avec_le_plafond_sature( + temp_runtime_db, etat_moteur, discussion +): + """Plafond atteint par des exécutions lourdes : un tour vocal en mode + Discussion répond quand même — il ne demande aucun créneau.""" + for i in range(etat_moteur.max_concurrency): + _occuper(etat_moteur, f"session_lourde_{i}") + + result = await route_agents.execute_chat(_corps()) + + assert result["status"] == "completed", result + assert result["response"] == "Il fait 21 degrés dans le salon." + assert discussion.await_count == 1 + + +@pytest.mark.asyncio +async def test_second_tour_vocal_du_meme_device_n_est_plus_un_doublon( + temp_runtime_db, etat_moteur, discussion +): + """La clé d'exécution du Tab5 retombe sur son `device_id` : un second tour + vocal pendant qu'un premier est en vol se prenait un 409 « doublon ».""" + corps = _corps(device_id="tab5_salon") + cle_du_device = _cle_execution(None, parse_source(corps.source)) + _occuper(etat_moteur, cle_du_device, objectif="premier tour vocal") + + result = await route_agents.execute_chat(corps) + + assert result["status"] == "completed", result + assert discussion.await_count == 1 + + +@pytest.mark.asyncio +async def test_discussion_ne_prend_aucun_creneau( + temp_runtime_db, etat_moteur, discussion +): + """Ni entrée ajoutée au registre, ni vue agrégée touchée : le tour vocal + passe sans laisser de trace de concurrence.""" + _occuper(etat_moteur, "session_lourde") + + await route_agents.execute_chat(_corps(device_id="tab5_salon")) + + assert list(etat_moteur.execution_registry) == ["session_lourde"], ( + "le tour Discussion a pris un créneau ou laissé une entrée orpheline" + ) + assert etat_moteur.execution_state["status"] == "running", ( + "le tour Discussion a écrasé la vue agrégée d'une exécution en cours" + ) + assert etat_moteur.execution_state["objective"] == "un DAG en cours" + + +@pytest.mark.asyncio +async def test_discussion_au_repos_ne_declare_rien( + temp_runtime_db, etat_moteur, discussion +): + """Sans exécution concurrente : le mode Discussion n'a pas de raison + d'annoncer `running` à l'IHM pour un tour de 2 s.""" + await route_agents.execute_chat(_corps()) + + assert etat_moteur.execution_registry == {} + assert etat_moteur.execution_state["status"] == "idle" + + +# ── L'analyse du routeur n'est plus payée pour rien ────────────────────────── + +@pytest.mark.asyncio +async def test_discussion_n_appelle_pas_le_routeur( + temp_runtime_db, etat_moteur, discussion, monkeypatch +): + """`analyze_request()` était appelé puis jeté : le `routing_type` retenu + vient de `host_result`, et ni `initial_payload` ni `starting_agent` n'y + servent. Le routeur ne doit plus être sollicité du tout.""" + faux_routeur = MagicMock() + monkeypatch.setattr(etat_moteur, "get_shared_router", faux_routeur) + + result = await route_agents.execute_chat(_corps()) + + assert result["status"] == "completed" + faux_routeur.assert_not_called() + + +# ── Non-régression : contrat rendu au Tab5 et historique de conversation ──── + +@pytest.mark.asyncio +async def test_forme_de_reponse_inchangee( + temp_runtime_db, etat_moteur, discussion +): + """Agent, routing_type, métadonnées du host et `agents_used` viennent tous + de `host_result`, comme avant.""" + result = await route_agents.execute_chat(_corps()) + + entree = result["history"][0] + assert entree["agent_name"] == "vocal_host" + assert entree["metadata"]["routing_type"] == "discussion_chat" + assert entree["metadata"]["model_tier"] == "leger" + assert entree["metadata"]["modele"] == "faux-modele", "métadonnées du host perdues" + assert result["agents_used"] == ["vocal_host"] + + +@pytest.mark.asyncio +async def test_les_deux_tours_de_conversation_sont_enregistres( + temp_runtime_db, etat_moteur, discussion, monkeypatch +): + """Avec un `conversation_id`, le tour utilisateur ET le tour assistant sont + enregistrés — une perte silencieuse casserait le multi-tour.""" + tours = [] + monkeypatch.setattr( + "core.vocal_session.record_vocal_turn", + lambda conv_id, role, texte, **kw: tours.append((conv_id, role, texte)), + ) + + await route_agents.execute_chat(_corps(conversation_id="conv_42")) + + assert [(c, r) for c, r, _ in tours] == [ + ("conv_42", "user"), ("conv_42", "assistant"), + ], tours + + +@pytest.mark.asyncio +async def test_echec_du_host_rend_le_repli_sans_toucher_au_registre( + temp_runtime_db, etat_moteur, discussion +): + """Si `handle_discussion` échoue, le repli part quand même et le registre + d'une exécution concurrente reste intact.""" + discussion.side_effect = RuntimeError("passerelle indisponible") + _occuper(etat_moteur, "session_lourde") + + result = await route_agents.execute_chat(_corps()) + + assert result["response"], result + assert list(etat_moteur.execution_registry) == ["session_lourde"] + + +# ── Garde-fou : le portillon protège TOUJOURS les chemins lourds ──────────── + +@pytest.mark.asyncio +async def test_le_portillon_refuse_toujours_le_doublon_hors_discussion( + temp_runtime_db, etat_moteur, discussion +): + """Sortir Discussion du registre ne doit pas ouvrir le portillon pour les + autres : un appel hors mode Discussion, sur une clé déjà en vol, reçoit + toujours 409 — l'Engine partagé n'accepte pas deux fois la même session.""" + corps = route_agents.ExecuteRequestBody( + user_prompt="refactore le module de routage", + source={"type": "ide", "mode": "default", "tts_enabled": False}, + session_id="session_ide", + ) + _occuper(etat_moteur, "session_ide") + + with pytest.raises(HTTPException) as exc: + await route_agents.execute_chat(corps) + + assert exc.value.status_code == 409 + assert discussion.await_count == 0, "le chemin Discussion n'a rien à faire ici" + + +@pytest.mark.asyncio +async def test_le_plafond_refuse_toujours_un_chemin_lourd( + temp_runtime_db, etat_moteur, discussion, monkeypatch +): + """Et le plafond de concurrence tient toujours pour les chemins lourds. + + [#T344] Le portillon est désormais posé après `analyze_request` (juste avant + le pipeline) : ce test mocke le routeur pour router vers le pipeline sans + appeler le LLM, puis vérifie que le plafond refuse toujours la requête.""" + # Route vers le pipeline complet (chemin lourd) sans appeler le LLM. + payload = SimpleNamespace(metadata={"routing_type": "default"}) + faux_routeur = MagicMock() + faux_routeur.analyze_request = AsyncMock(return_value=(payload, "planner")) + monkeypatch.setattr(etat_moteur, "get_shared_router", + MagicMock(return_value=faux_routeur)) + + for i in range(etat_moteur.max_concurrency): + _occuper(etat_moteur, f"session_lourde_{i}") + corps = route_agents.ExecuteRequestBody( + user_prompt="refactore le module de routage", + source={"type": "ide", "mode": "default", "tts_enabled": False}, + session_id="une_autre_session", + ) + + with pytest.raises(HTTPException) as exc: + await route_agents.execute_chat(corps) + + assert exc.value.status_code == 409 diff --git a/tests/unit/test_execution_per_session_t324.py b/tests/unit/test_execution_per_session_t324.py new file mode 100644 index 0000000..960b204 --- /dev/null +++ b/tests/unit/test_execution_per_session_t324.py @@ -0,0 +1,184 @@ +"""Tests du verrou d'exécution PAR SESSION (#T324). + +Remplace l'ancien verrou global unique : deux sessions DIFFÉRENTES peuvent +s'exécuter en parallèle, une même session ne s'exécute jamais deux fois +(409 doublon) et un plafond global de concurrence reste appliqué. `execution_state` +garde exactement sa forme (4 clés) en vue agrégée : `status == "running"` dès +qu'au moins une exécution tourne. + +Critères d'acceptation couverts ici : + 1. Deux exécutions de la MÊME session en parallèle → la seconde reçoit 409. + 2. Deux exécutions de sessions DIFFÉRENTES en parallèle → les deux passent. + 3. Au-delà du plafond de concurrence → 409, message distinct du doublon. + 4. Une exécution qui lève une exception libère bien son entrée (session + suivante acceptée). + 5. GET /api/status renvoie toujours les quatre mêmes clés, et + `status == "running"` dès qu'une exécution tourne. +""" + +import pytest + +from core.app_state import ( + MSG_DOUBLON_SESSION, + MSG_PLAFOND_CONCURRENCE, + _cle_execution, + get_app_state, +) +from core.source_router import ModeType, RequestSource, SourceType + + +@pytest.fixture +def etat(): + """Renvoie le singleton AppState réinitialisé (registre vide, plafond 3).""" + import core.app_state as app_state + + # Réinitialise le singleton pour isoler chaque test (pas de fuite entre eux). + app_state._app_state_instance._initialized = False + st = get_app_state() + st.execution_registry.clear() + st.execution_state.update({ + "status": "idle", "objective": "", "engine_state": None, "error_message": None, + }) + st.max_concurrency = 3 + return st + + +def _requete_source(device_id: str | None = None) -> RequestSource: + """RequestSource du Tab5 HA (source de repli de la clé d'exécution).""" + return RequestSource( + type=SourceType.TAB5, mode=ModeType.HA, tts_enabled=True, device_id=device_id + ) + + +# ── 1. Même session → jamais deux en parallèle (garde-fou anti-doublon) ── + +@pytest.mark.asyncio +async def test_meme_session_refusee_deux_fois(etat): + """Deux exécutions de la MÊME session : la seconde reçoit 409 doublon.""" + refus1 = await etat.begin_execution("session_A", "objectif 1") + assert refus1 is None # première acceptée + + refus2 = await etat.begin_execution("session_A", "objectif 2") + assert refus2 is not None + assert refus2["status_code"] == 409 + assert refus2["detail"] == MSG_DOUBLON_SESSION + + # Après libération, la même session redevient acceptée. + await etat.end_execution("session_A") + refus3 = await etat.begin_execution("session_A", "objectif 3") + assert refus3 is None + + +@pytest.mark.asyncio +async def test_repli_source_device_identique_protege_du_doublon(etat): + """Sans session_id, la clé retombe sur la source : un même device (Tab5) + qui rejoue une commande vocale est bloqué en doublon (garde-fou).""" + src1 = _requete_source(device_id="tab5-001") + src2 = _requete_source(device_id="tab5-001") + assert _cle_execution(None, src1) == _cle_execution(None, src2) + + refus1 = await etat.begin_execution(_cle_execution(None, src1), "ouvre le volet") + assert refus1 is None + refus2 = await etat.begin_execution(_cle_execution(None, src2), "ouvre le volet") + assert refus2 is not None + assert refus2["status_code"] == 409 + assert refus2["detail"] == MSG_DOUBLON_SESSION + + +# ── 2. Sessions différentes → parallélisme autorisé ── + +@pytest.mark.asyncio +async def test_sessions_differentes_en_parallele(etat): + """Deux sessions DIFFÉRENTES s'exécutent en parallèle (c'est le test qui + échouait sur master : le verrou global renvoyait 409 à la seconde).""" + refus_a = await etat.begin_execution("session_A", "objectif A") + refus_b = await etat.begin_execution("session_B", "objectif B") + assert refus_a is None + assert refus_b is None + + # Les deux entrées coexistent dans le registre. + assert set(etat.execution_registry) == {"session_A", "session_B"} + + # La vue agrégée reste "running" tant qu'au moins une tourne. + assert etat.execution_state["status"] == "running" + + await etat.end_execution("session_A") + # B tourne encore → toujours "running". + assert etat.execution_state["status"] == "running" + assert "session_B" in etat.execution_registry + + await etat.end_execution("session_B") + assert etat.execution_state["status"] == "success" + assert etat.execution_registry == {} + + +# ── 3. Plafond de concurrence global ── + +@pytest.mark.asyncio +async def test_au_dela_du_plafond_409_message_distinct(etat): + """Au-delà du plafond → 409 avec un message distinct de celui du doublon.""" + etat.max_concurrency = 2 + assert await etat.begin_execution("session_A", "A") is None + assert await etat.begin_execution("session_B", "B") is None + + refus = await etat.begin_execution("session_C", "C") + assert refus is not None + assert refus["status_code"] == 409 + assert refus["detail"] == MSG_PLAFOND_CONCURRENCE + assert refus["detail"] != MSG_DOUBLON_SESSION + + # Une session libérée → la suivante redevient acceptée. + await etat.end_execution("session_A") + assert await etat.begin_execution("session_C", "C") is None + + +# ── 4. Exception → l'entrée est libérée (garde-fou anti-fuite) ── + +@pytest.mark.asyncio +async def test_exception_libère_l_entrée(etat): + """Une exécution qui lève libère bien son entrée : la session suivante passe.""" + async def _execution_qui_leve(session_key): + refus = await etat.begin_execution(session_key, "objectif") + assert refus is None + try: + raise RuntimeError("panne simulée") + finally: + await etat.end_execution(session_key, status="error", error_message="panne simulée") + + with pytest.raises(RuntimeError): + await _execution_qui_leve("session_A") + # L'entrée a été retirée → la même session est réutilisable. + refus = await etat.begin_execution("session_A", "objectif 2") + assert refus is None + assert etat.execution_state["status"] == "running" + assert etat.execution_state["error_message"] is None + + +# ── 5. GET /api/status : les 4 clés + running dès qu'une exécution tourne ── + +def test_status_renvoie_toujours_les_quatre_cles(etat): + """Le contrat public de /api/status : exactement les 4 clés historiques.""" + from api.routes.execution import get_status + + etat.execution_state.update({ + "status": "running", "objective": "obj", "engine_state": None, "error_message": None, + }) + corps = get_status() + assert set(corps.keys()) == {"status", "objective", "engine_state", "error_message"} + + +@pytest.mark.asyncio +async def test_status_running_des_qu_une_execution_tourne(etat): + """status == "running" dès qu'une exécution tourne, même si une autre vient + de se terminer (vue agrégée).""" + from api.routes.execution import get_status + + await etat.begin_execution("session_A", "A") + await etat.begin_execution("session_B", "B") + await etat.end_execution("session_B") + + # Une seule exécution (A) tourne encore → "running". + assert get_status()["status"] == "running" + + await etat.end_execution("session_A") + assert get_status()["status"] == "success" diff --git a/tests/unit/test_executor_json_illisible_t343.py b/tests/unit/test_executor_json_illisible_t343.py new file mode 100644 index 0000000..e389a1c --- /dev/null +++ b/tests/unit/test_executor_json_illisible_t343.py @@ -0,0 +1,242 @@ +""" +tests/unit/test_executor_json_illisible_t343.py — un JSON d'arguments illisible +n'exécute plus l'outil à vide (#T343-A). + +Défauts corrigés dans `agents/executor.py` (bloc #T341) : + (a) quand `json.loads(kwargs_str)` échouait, `kwargs = {}` et l'appel partait + à l'exécution SANS ses arguments, en silence — `set_temperature()` sans + température, `write_file()` sans chemin. Le message d'erreur construit + était écrasé sans condition par `_execute_tool_with_retry`. + (b) la clé de déduplication devenait `(nom, "{}")` pour TOUS les appels au + JSON invalide : `json.dumps({})` ne lève jamais, donc l'`except` censé + basculer sur la chaîne brute était inatteignable. Deux malformés + DIFFÉRENTS étaient vus comme un doublon exact. + +Correctif : un appel non décodable n'est JAMAIS exécuté, reçoit un message +`role: "tool"` qui nomme l'erreur de décodage (l'API exige une réponse pour +chaque tool_call du message assistant), et sa clé de déduplication repose sur +la chaîne BRUTE pour que deux malformés différents restent deux appels +différents. La borne MAX_TOOL_CALLS_PER_TURN et la déduplication nominale ne +sont pas touchées (`tests/unit/test_executor_borne_appels_outil_t341.py` +passe inchangé). + +Les tests pilotent la vraie boucle `_execute_react_loop` : seul +`provider.generate_async` est mocké, et `registry.execute` compte les +exécutions réelles. Aucun réseau, aucun LLM réel. +""" +import logging +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from agents.executor import ExecutorAgent + + +def _agent(compteur_executions: list[int]) -> tuple[ExecutorAgent, MagicMock]: + """ExecutorAgent minimal : passerelle et registre mockés, zéro réseau.""" + gateway = MagicMock() + gateway.get_provider_for_tier.return_value = ("modele-de-test", MagicMock()) + registry = MagicMock() + registry.get_all_schemas.return_value = [] + + async def _exec(name, kwargs, agent_name=None): + compteur_executions[0] += 1 + return "ok" + + registry.execute = AsyncMock(side_effect=_exec) + agent = ExecutorAgent(llm_gateway=gateway, tool_registry=registry) + agent._sandbox = None + return agent, registry + + +def _tool_call_brut(func_name: str, arguments_bruts: str, idx: int) -> dict: + """Un tool_call dont `arguments` est une chaîne brute (pas forcément du JSON).""" + return { + "id": f"call_{idx}", + "type": "function", + "function": {"name": func_name, "arguments": arguments_bruts}, + } + + +def _reponse_avec_tool_calls(tool_calls: list[dict]) -> dict: + return {"content": "", "tool_calls": tool_calls} + + +def _messages_tool(messages: list[dict]) -> list[dict]: + return [m for m in messages if m["role"] == "tool"] + + +# ── Test central : un JSON invalide n'exécute pas l'outil ──────────────────── + +@pytest.mark.asyncio +async def test_json_invalide_outil_non_execute_et_message_nomme_le_decodage(): + """Un tool_call aux arguments illisibles ne part pas à l'exécution à vide.""" + compteur = [0] + agent, registry = _agent(compteur) + + # JSON franchement invalide : pas d'accolade fermante, valeur non JSON. + arguments_bruts = '{"entity_id": "temperature.salon"' + tool_calls = [_tool_call_brut("get_state", arguments_bruts, 0)] + provider = MagicMock() + provider.generate_async = AsyncMock(side_effect=[ + _reponse_avec_tool_calls(tool_calls), + "Terminé.", + ]) + + messages: list[dict] = [] + _, final_text, tool_executed, last_error = await agent._execute_react_loop( + messages=messages, + user_prompt="quelle est la température du salon ?", + provider=provider, + tools_schemas=[], + max_turns=2, + session_id=None, + use_search_grounding=False, + ) + + # L'outil n'est PAS exécuté : zéro appel réel, zéro kwargs vide. + assert compteur[0] == 0, f"exécutions réelles : {compteur[0]}" + assert registry.execute.await_count == 0 + + # Le message `tool` renvoyé au modèle nomme l'erreur de décodage. + outils = _messages_tool(messages) + assert len(outils) == 1, f"{len(outils)} messages tool" + contenu = outils[0]["content"] + assert "arguments JSON invalides" in contenu + assert "outil non exécuté" in contenu + assert "Expecting" in contenu or "Unterminated" in contenu or "JSON" in contenu + + # Le tour reste valide : l'agent répond au tour suivant. + assert tool_executed is False + assert last_error is None + assert final_text == "Terminé." + + +# ── Deux malformés différents ne sont pas des doublons l'un de l'autre ─────── + +@pytest.mark.asyncio +async def test_deux_json_invalides_differents_ne_sont_pas_des_doublons(): + """Deux malformés DIFFÉRENTS restent deux appels, aucun n'est un doublon.""" + compteur = [0] + agent, registry = _agent(compteur) + + # Deux chaînes brutes différentes, toutes deux invalides. + tool_calls = [ + _tool_call_brut("get_state", '{"entity_id": "temperature.salon"', 0), + _tool_call_brut("get_state", '{"entity_id": "temperature.chambre"', 1), + ] + provider = MagicMock() + provider.generate_async = AsyncMock(side_effect=[ + _reponse_avec_tool_calls(tool_calls), + "Terminé.", + ]) + + messages: list[dict] = [] + _, final_text, tool_executed, last_error = await agent._execute_react_loop( + messages=messages, + user_prompt="quelles températures ?", + provider=provider, + tools_schemas=[], + max_turns=2, + session_id=None, + use_search_grounding=False, + ) + + # Aucun des deux n'est exécuté, et aucun n'est traité comme doublon de + # l'autre : chacun reçoit son propre message d'erreur de décodage. + assert compteur[0] == 0 + outils = _messages_tool(messages) + assert len(outils) == 2, f"{len(outils)} messages tool" + assert all("non exécuté" in m["content"] for m in outils) + assert all("doublon" not in m["content"] for m in outils), [ + m["content"] for m in outils + ] + assert all("arguments JSON invalides" in m["content"] for m in outils) + + # Les deux tool_call_id distincts ont chacun leur réponse (règle API). + assert {m["tool_call_id"] for m in outils} == {"call_0", "call_1"} + assert tool_executed is False + assert last_error is None + assert final_text == "Terminé." + + +# ── Le résumé du tour ne confond pas JSON illisible et borne ──────────────── + +@pytest.mark.asyncio +async def test_log_distingue_json_illisible_de_la_borne(caplog): + """Un tour avec UNIQUEMENT des JSON invalides ne s'annonce pas comme un + dépassement de MAX_TOOL_CALLS_PER_TURN : le compteur « au-delà de la + borne » ne compte que les rejets de borne (revue Bugbot PR #301).""" + compteur = [0] + agent, registry = _agent(compteur) + + tool_calls = [ + _tool_call_brut("get_state", '{"entity_id": "temperature.salon"', 0), + _tool_call_brut("get_state", '{pas du json', 1), + ] + provider = MagicMock() + provider.generate_async = AsyncMock(side_effect=[ + _reponse_avec_tool_calls(tool_calls), + "Terminé.", + ]) + + messages: list[dict] = [] + with caplog.at_level(logging.WARNING, logger="agents.executor"): + await agent._execute_react_loop( + messages=messages, + user_prompt="températures", + provider=provider, + tools_schemas=[], + max_turns=2, + session_id=None, + use_search_grounding=False, + ) + + assert compteur[0] == 0 + resume = [r.getMessage() for r in caplog.records + if "appel(s) d'outil écarté(s) ce tour" in r.getMessage()] + assert len(resume) == 1, resume + assert "2 JSON illisible(s)" in resume[0], resume[0] + assert "0 au-delà de la borne" in resume[0], resume[0] + + +# ── Non-régression : le nominal et les garde-fous ne bougent pas ───────────── + +@pytest.mark.asyncio +async def test_appel_valide_et_malforme_coexistent(): + """Un appel valide s'exécute, un appel malformé du même tour ne l'est pas.""" + compteur = [0] + agent, registry = _agent(compteur) + + tool_calls = [ + _tool_call_brut("get_state", '{"entity_id": "temperature.salon"}', 0), + _tool_call_brut("get_state", '{pas du json', 1), + ] + provider = MagicMock() + provider.generate_async = AsyncMock(side_effect=[ + _reponse_avec_tool_calls(tool_calls), + "Terminé.", + ]) + + messages: list[dict] = [] + last_results, final_text, tool_executed, last_error = await agent._execute_react_loop( + messages=messages, + user_prompt="température du salon", + provider=provider, + tools_schemas=[], + max_turns=2, + session_id=None, + use_search_grounding=False, + ) + + # Le valide est exécuté (1), le malformé ne l'est pas. + assert compteur[0] == 1, f"exécutions réelles : {compteur[0]}" + outils = _messages_tool(messages) + assert len(outils) == 2 + non_executes = [m for m in outils if "non exécuté" in m["content"]] + assert len(non_executes) == 1 + assert "arguments JSON invalides" in non_executes[0]["content"] + assert last_results == ["Résultat 'get_state' : ok"] + assert tool_executed is True + assert last_error is None + assert final_text == "Terminé." diff --git a/tests/unit/test_gateway_direct_cb.py b/tests/unit/test_gateway_direct_cb.py index 46cf156..28cb5b0 100644 --- a/tests/unit/test_gateway_direct_cb.py +++ b/tests/unit/test_gateway_direct_cb.py @@ -108,10 +108,68 @@ def test_routing_policy_reelle_exclut_fable5(): exécutable — empêche une session future de la retirer par erreur).""" from core.llm_gateway import load_config policy = load_config().get("routing_policy", {}) - excluded = policy.get("excluded_models", []) - if not excluded: - pytest.skip("config d'exemple OSS sans routing_policy") - assert "claude-fable-5" in excluded + exclus = policy.get("excluded_models", []) + assert "claude-fable-5" in exclus + assert "claude-sonnet-5" in exclus + assert "claude-opus-4-8-direct" in exclus + assert "meta-llama/llama-3.3-70b-instruct:free" in exclus + + +def test_exclusions_defaut_couvrent_api_anthropic_et_or_free(): + """13/08 : le défaut code doit suffire même si le config Deck est figé.""" + from core.llm_gateway import EXCLUSIONS_CASCADE_DEFAUT + assert "claude-sonnet-5" in EXCLUSIONS_CASCADE_DEFAUT + assert "claude-opus-4-8-direct" in EXCLUSIONS_CASCADE_DEFAUT + assert "claude-haiku-4-5-direct" in EXCLUSIONS_CASCADE_DEFAUT + assert "meta-llama/llama-3.2-3b-instruct:free" in EXCLUSIONS_CASCADE_DEFAUT + assert "dashscope/qwen3-coder-next" in EXCLUSIONS_CASCADE_DEFAUT + + +def test_exclusions_defaut_filtrent_meme_sans_config(gateway, monkeypatch): + """Le défaut code filtre même si routing_policy.excluded_models est vide + (cas du config.json Deck figé au deploy).""" + import core.models_db as models_db + + monkeypatch.setattr(models_db, "get_models_for_tier", lambda _tier: []) + config = { + "tiers": { + "leger": [ + "meta-llama/llama-3.2-3b-instruct:free", + "gemini-3.5-flash-free", + ] + }, + "routing_policy": {"excluded_models": []}, + } + _tier, allowed = gateway._resolve_tier_models("leger", config) + assert "meta-llama/llama-3.2-3b-instruct:free" not in allowed + assert "gemini-3.5-flash-free" in allowed + + +def test_exclusions_defaut_filtrent_prefixe_dashscope(gateway, monkeypatch): + """D-8 : un dashscope/* inconnu de la liste exacte est quand même filtré.""" + import core.models_db as models_db + + monkeypatch.setattr(models_db, "get_models_for_tier", lambda _tier: []) + config = { + "tiers": { + "fort": [ + "dashscope/modele-invente", + "deepseek-reasoner", + ] + }, + "routing_policy": {"excluded_models": []}, + } + _tier, allowed = gateway._resolve_tier_models("fort", config) + assert "dashscope/modele-invente" not in allowed + assert "deepseek-reasoner" in allowed + + +def test_gateway_plus_de_slugs_or_free(gateway): + """Les slugs :free ne sont plus des providers — plus de 404 en tête de cascade.""" + assert "meta-llama/llama-3.3-70b-instruct:free" not in gateway.providers + assert "meta-llama/llama-3.2-3b-instruct:free" not in gateway.providers + if "openrouter" in gateway.providers: + assert "openrouter/auto" in gateway.providers def test_tier_sans_double_wrapping(gateway): diff --git a/tests/unit/test_ha_climate_advanced.py b/tests/unit/test_ha_climate_advanced.py index e547292..41024ea 100644 --- a/tests/unit/test_ha_climate_advanced.py +++ b/tests/unit/test_ha_climate_advanced.py @@ -10,6 +10,13 @@ ) +# [T356] Depuis que execute_ha_service vérifie l'état de l'entité avant le POST, +# les tests du POST simulent une entité VIVANTE (sinon la lecture d'état ferait +# un vrai GET réseau, bloqué par le garde-fou). read_ha_state est async. +async def _etat_vivant(entity_id: str) -> dict: + return {"entity_id": entity_id, "state": "off", "attributes": {}} + + # ── Item 1 : nombres en lettres ── def test_parse_temperature_digits_and_words(): @@ -78,6 +85,7 @@ async def test_set_temperature_with_mode_splits_in_two_calls(monkeypatch): fake = _FakeSession() monkeypatch.setattr(es, "_get_ha_session", lambda: fake) monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _etat_vivant) ok, text = await es.execute_ha_service( "climate.set_temperature", @@ -100,6 +108,7 @@ async def test_set_temperature_without_mode_single_call(monkeypatch): fake = _FakeSession() monkeypatch.setattr(es, "_get_ha_session", lambda: fake) monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _etat_vivant) ok, _ = await es.execute_ha_service( "climate.set_temperature", diff --git a/tests/unit/test_ha_commande_abandonnee_t323.py b/tests/unit/test_ha_commande_abandonnee_t323.py index b077f0d..8caab63 100644 --- a/tests/unit/test_ha_commande_abandonnee_t323.py +++ b/tests/unit/test_ha_commande_abandonnee_t323.py @@ -30,6 +30,13 @@ import services.execute_service as es from services.execute_service import _est_action_rejouable + +# [T356] Depuis que execute_ha_service vérifie l'état de l'entité avant le POST, +# les tests du POST doivent simuler une entité VIVANTE (sinon la lecture d'état +# ferait un vrai GET réseau, bloqué par le garde-fou). read_ha_state est async. +async def _etat_vivant(entity_id: str) -> dict: + return {"entity_id": entity_id, "state": "off", "attributes": {}} + # ── (a) Reprise sur connexion fermée, strictement bornée ───────────────────── class _FakeResp: @@ -70,14 +77,14 @@ async def __aexit__(self, *a): @pytest.mark.asyncio async def test_action_rejouable_repart_sur_une_connexion_neuve(monkeypatch): - """Volet : la connexion morte du pool ne doit pas perdre la commande.""" + """Service rejouable : la connexion morte du pool ne doit pas perdre la commande.""" session = _SessionQuiCoupe(coupures=1) monkeypatch.setattr(es, "_get_ha_session", lambda: session) monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _etat_vivant) ok, texte = await es.execute_ha_service( - "script.blind_action", "cover.volet_salon", - service_data={"action": "close"}, + "light.turn_off", "light.living_room", ) assert ok, "la commande a été perdue alors qu'elle était rejouable sans risque" @@ -85,12 +92,37 @@ async def test_action_rejouable_repart_sur_une_connexion_neuve(monkeypatch): assert texte +@pytest.mark.asyncio +async def test_script_volet_n_est_plus_rejoue(monkeypatch): + """ + Depuis #T350 le volet passe par script.turn_on et son script est en + `mode: restart` : rejouer annulerait la première exécution. Une connexion + coupée doit donc échouer franchement, sans seconde tentative. + """ + session = _SessionQuiCoupe(coupures=1) + monkeypatch.setattr(es, "_get_ha_session", lambda: session) + monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _etat_vivant) + + with pytest.raises(aiohttp.ServerDisconnectedError): + await es.execute_ha_service( + "script.blind_action", "cover.volet_salon", + service_data={"action": "close"}, + ) + + assert session.tentatives == 1, ( + "le script volet a été rejoué alors que mode:restart annulerait " + "l'exécution déjà lancée" + ) + + @pytest.mark.asyncio async def test_action_non_rejouable_echoue_franchement(monkeypatch): """`toggle` ne doit JAMAIS être rejoué : deux inversions = retour à l'état initial.""" session = _SessionQuiCoupe(coupures=1) monkeypatch.setattr(es, "_get_ha_session", lambda: session) monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _etat_vivant) with pytest.raises(aiohttp.ServerDisconnectedError): await es.execute_ha_service("light.toggle", "light.living_room") @@ -104,23 +136,28 @@ async def test_deux_coupures_de_suite_ne_boucle_pas(monkeypatch): session = _SessionQuiCoupe(coupures=5) monkeypatch.setattr(es, "_get_ha_session", lambda: session) monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _etat_vivant) with pytest.raises(aiohttp.ServerDisconnectedError): await es.execute_ha_service( - "script.blind_action", "cover.volet_salon", - service_data={"action": "close"}, + "light.turn_off", "light.living_room", ) assert session.tentatives == 2 def test_liste_blanche_des_actions_rejouables(): - """L'idempotence de l'effet PHYSIQUE est le seul critère.""" + """L'idempotence de l'effet PHYSIQUE est le seul critère. + + Depuis #T350 le script volet n'est plus rejouable : il passe par + script.turn_on et son script HA est en `mode: restart` (une seconde + commande annulerait la première). + """ for rejouable in ("light.turn_on", "cover.close_cover", "cover.open_cover", - "climate.set_temperature", "script.blind_action"): + "climate.set_temperature"): assert _est_action_rejouable(rejouable), rejouable for interdit in ("light.toggle", "cover.toggle", "script.un_script_inconnu", - "vacuum.start", "lock.unlock"): + "vacuum.start", "lock.unlock", "script.blind_action"): assert not _est_action_rejouable(interdit), interdit diff --git a/tests/unit/test_ha_entite_morte_t356.py b/tests/unit/test_ha_entite_morte_t356.py new file mode 100644 index 0000000..62cf462 --- /dev/null +++ b/tests/unit/test_ha_entite_morte_t356.py @@ -0,0 +1,234 @@ +""" +#T356 — Ne plus annoncer un succès sur un appareil déconnecté. + +Le moteur n'inspectait jamais l'état de l'entité qu'il pilote : il envoyait le +service, lisait le code HTTP (200 = succès) et annonçait « Volet fermé » alors +que l'appareil `unavailable` n'avait rien exécuté. Mesuré en prod le 17/08 : +146 entités `unavailable`/`unknown`, dont 17 pilotables (cover.living_room_blind, +6 light.sonoff_*, 7 switch.sonoff_*…). + +Correctif : avant d'exécuter une commande domotique, lire l'état de l'entité +visée (réutilise `read_ha_state`, déjà présent — pas de troisième chemin). Si +elle est `unavailable` ou `unknown`, ne PAS annoncer un succès : rendre une +phrase qui nomme l'appareil qui ne répond pas. Une lecture d'état indisponible +(HA muet, erreur réseau) n'est PAS « l'appareil est mort » : la commande part +quand même, comme avant. +""" + +import pytest + +import services.execute_service as es + + +class _FakeResp: + def __init__(self, status=200): + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def text(self): + return "" + + +class _FakeSession: + """Enregistre les POST HA (et refuse tout GET réseau, jamais atteint ici).""" + + def __init__(self): + self.calls: list[tuple[str, dict]] = [] + + def post(self, url, json=None, headers=None, timeout=None): + self.calls.append((url, json)) + return _FakeResp(200) + + +def _etat_vivant(entity_id: str = "light.living_room") -> dict: + return {"entity_id": entity_id, "state": "off", "attributes": {}} + + +def _fake_read_ha_state(etat: dict | None): + """Retourne une coroutine mockant `read_ha_state` (fonction async).""" + async def _lecture(_entity_id: str) -> dict | None: + return etat + return _lecture + + +# ── LE TEST CENTRAL : entité `unavailable`, HA répond 200 → pas de succès ───── + +@pytest.mark.asyncio +async def test_entite_unavailable_ne_annonce_pas_de_succes(monkeypatch): + """ + Une commande visant une entité `unavailable` ne produit JAMAIS une phrase de + succès, même si HA répondrait HTTP 200 au POST. La phrase rendue nomme + l'appareil qui ne répond pas. Aucun POST ne doit partir (inutile d'envoyer + une commande à un appareil mort). + """ + fake = _FakeSession() + monkeypatch.setattr(es, "_get_ha_session", lambda: fake) + monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + # L'entité visée est `unavailable` : HA répondrait 200 au POST, mais l'appareil + # n'exécute rien. C'est exactement le mensonge que #T356 combat. + monkeypatch.setattr( + es, + "read_ha_state", + _fake_read_ha_state({ + "entity_id": "cover.living_room_blind", + "state": "unavailable", + "attributes": {"friendly_name": "Volet serre rideau"}, + }), + ) + + ok, texte = await es.execute_ha_service( + "script.blind_action", "", service_data={"action": "close"}, + ) + + assert ok is False, "une entité unavailable ne doit pas être annoncée comme exécutée" + assert fake.calls == [], "aucun POST ne doit partir vers une entité morte" + # La phrase nomme l'appareil qui ne répond pas — pas un refus générique. + assert "ne répond pas" in texte + assert "volet" in texte.lower() + + +@pytest.mark.asyncio +async def test_entite_unknown_ne_annonce_pas_de_succes(monkeypatch): + """`unknown` est traité comme `unavailable` : pas de succès, phrase nommée.""" + fake = _FakeSession() + monkeypatch.setattr(es, "_get_ha_session", lambda: fake) + monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr( + es, + "read_ha_state", + _fake_read_ha_state({ + "entity_id": "light.sonoff_x", + "state": "unknown", + "attributes": {"friendly_name": "Sonoff salon"}, + }), + ) + + ok, texte = await es.execute_ha_service("light.turn_on", "light.sonoff_x") + + assert ok is False + assert fake.calls == [] + assert "ne répond pas" in texte + assert "sonoff" in texte.lower() + + +# ── LE TEST JUMEAU : entité vivante → exactement la réponse d'aujourd'hui ───── + +@pytest.mark.asyncio +async def test_entite_vivante_repond_exactement_comme_avant(monkeypatch): + """ + Une commande vers une entité vivante répond exactement comme aujourd'hui : + même phrase, et un seul POST (la lecture d'état ne déclenche aucun appel + POST supplémentaire). C'est la non-régression du chemin nominal. + """ + fake = _FakeSession() + monkeypatch.setattr(es, "_get_ha_session", lambda: fake) + monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _fake_read_ha_state(_etat_vivant())) + + ok, texte = await es.execute_ha_service("light.turn_off", "light.living_room") + + assert ok is True + # Phrase canonique TTS existante (cache) — identique à aujourd'hui. + assert texte == "Lumière du salon éteinte.", f"la phrase a changé : {texte!r}" + # Un seul POST, comme avant (aucun appel supplémentaire inutile). + assert len(fake.calls) == 1 + url, _ = fake.calls[0] + assert url.endswith("/api/services/light/turn_off") + + +# ── Lecture d'état indisponible → la commande part quand même ───────────────── + +@pytest.mark.asyncio +async def test_lecture_etat_indisponible_laisse_partir_la_commande(monkeypatch): + """ + Une lecture d'état en échec (HA muet, erreur réseau, entité absente du + cache) n'est PAS « l'appareil est mort » : la commande part quand même et le + comportement reste celui d'aujourd'hui. Un moteur qui refuse d'agir dès qu'il + doute est inutilisable. + """ + fake = _FakeSession() + monkeypatch.setattr(es, "_get_ha_session", lambda: fake) + monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + # read_ha_state retourne None (lecture indisponible) — on ne sait pas si + # l'appareil est mort, donc on exécute comme avant. + monkeypatch.setattr(es, "read_ha_state", _fake_read_ha_state(None)) + + ok, texte = await es.execute_ha_service("light.turn_off", "light.living_room") + + assert ok is True + assert texte == "Lumière du salon éteinte." + assert len(fake.calls) == 1, "la commande doit partir malgré la lecture indisponible" + + +# ── Le matcher privilégie l'entité vivante à score voisin ───────────────────── + +def test_matcher_choisit_la_vivante_a_score_voisin(): + """ + Entre deux entités de score voisin dont une est morte, le matcher choisit la + vivante. Le parc contient des doublons morts (six light.sonoff_* indisponibles) + qui concurrencent les entités réelles au moment du choix. + """ + from core.ha_fuzzy_matcher import HAFuzzyMatcher + + matcher = HAFuzzyMatcher("https://ha.local", "tok") + + # Deux entités « Salon » au nom identique : les scores SANS pénalité sont + # EXACTEMENT égaux. L'une est vivante, l'autre morte (unavailable). Sans + # correctif, le matcher serait ambigu (refus) ou choisirait au hasard ; avec + # la pénalité sur la morte, la vivante doit l'emporter. + entities = { + "light.living_room": { + "entity_id": "light.living_room", + "domain": "light", + "friendly_name": "Salon", + "normalized_name": "salon", + "normalized_id": "salon", + "state": "on", + }, + "light.kitchen": { + "entity_id": "light.kitchen", + "domain": "light", + "friendly_name": "Salon", + "normalized_name": "salon", + "normalized_id": "salon", + "state": "unavailable", + }, + } + + # À score brut égal, la pénalité départage au profit de la vivante. + score_vivante = matcher._score_entity("salon", entities["light.living_room"]) + score_morte = matcher._score_entity("salon", entities["light.kitchen"]) + assert score_vivante > score_morte, ( + "à score voisin, la vivante doit sortir devant la morte" + ) + + # Et surtout, le choix final via find_entity retient l'entité vivante. + async def _fake_load(): + return entities + + matcher._load_entities = _fake_load # type: ignore[assignment] + matcher._lm_studio_online = False # force le chemin difflib (pas d'embedding) + + import asyncio + result = asyncio.run(matcher.find_entity("allume le salon")) + + assert result is not None + assert result.entity_id == "light.living_room", ( + "le matcher doit choisir l'entité vivante, pas le doublon mort" + ) + + +def test_est_entite_morte(): + """`unavailable` et `unknown` sont mortes ; tout le reste est vivant.""" + from core.ha_fuzzy_matcher import _est_entite_morte + + assert _est_entite_morte({"state": "unavailable"}) is True + assert _est_entite_morte({"state": "unknown"}) is True + assert _est_entite_morte({"state": "on"}) is False + assert _est_entite_morte({"state": "off"}) is False + assert _est_entite_morte({}) is False diff --git a/tests/unit/test_ha_question_pas_ordre_t363.py b/tests/unit/test_ha_question_pas_ordre_t363.py new file mode 100644 index 0000000..c6b943f --- /dev/null +++ b/tests/unit/test_ha_question_pas_ordre_t363.py @@ -0,0 +1,70 @@ +""" +[#T363] Une question ne doit jamais actionner la maison. + +Mesuré en prod le 17/08 par balayage de l'usage réel : « est-ce que la lumière +du salon est allumée ? » produisait `light.turn_on` sur `light.living_room`, et +« État de la lumière de la chambre. » produisait `light.turn_off` sur +`light.bedroom`. La réponse rendue (« Lumière de la chambre éteinte. ») est +indiscernable d'un rapport d'état : l'utilisateur croit interroger, il commande. + +Ces tests verrouillent les deux sens : les questions rendent la main à la +cascade (qui les traitera en lecture d'état), les ordres restent des ordres. +""" + +import pytest + +from services.execute_service import match_ha_command + +# ── Les questions ne sont pas des ordres ── + +@pytest.mark.parametrize("phrase", [ + # Formulations exactes relevées dans vocal_audit_log (prod). + "est-ce que la lumiere du salon est allumee ?", + "Est-elle a lumiere de la chambre ?", + "Est-elle un lumiere de la chambre ?", + "Etat de la lumiere de la chambre.", + # Variantes de la même famille. + "la clim est allumee ?", + "le volet du salon est-il ouvert ?", + "quel est l'etat de la lumiere du salon ?", + "combien de degres dans le salon ?", +]) +def test_une_question_ne_produit_aucune_commande(phrase): + assert match_ha_command(phrase) is None + + +# ── Les ordres restent des ordres ── + +@pytest.mark.parametrize("phrase,service", [ + ("Allume la lumiere de la chambre.", "light.turn_on"), + ("eteins la lumiere du salon", "light.turn_off"), + ("ferme le volet", "script.blind_action"), + ("mets la clim a 23 degres", "climate.set_temperature"), + # Ordre poli formulé en question : l'infinitif d'action tranche. + ("tu peux allumer la lumiere du salon ?", "light.turn_on"), + ("peux-tu fermer le volet ?", "script.blind_action"), + # Transcriptions STT réelles d'ordres, sans point d'interrogation. + ("Decembre le volet du salon", "script.blind_action"), + ("et tel les lumieres du salon", "light.turn_off"), +]) +def test_un_ordre_reste_une_commande(phrase, service): + cmd = match_ha_command(phrase) + assert cmd is not None, f"{phrase!r} doit rester une commande" + assert cmd.service == service + + +def test_est_elle_sans_point_interrogation_reste_une_commande(): + """ + Arbitrage assumé : `normalize_vocal_stt` réécrit « est-elle » en « éteins » + (`core/vocal_stt_normalize.py:38`) parce que le STT confond réellement les + deux. Sans point d'interrogation, on garde donc la lecture « ordre » — sinon + on perdrait de vraies commandes mal transcrites. C'est le « ? » qui tranche. + """ + cmd = match_ha_command("Est-elle la lumiere de la chambre") + assert cmd is not None and cmd.service == "light.turn_off" + + +def test_premier_mot_imperatif_prime_sur_la_forme_interrogative(): + """« Allume la lumière ? » reste un ordre : l'impératif explicite l'emporte.""" + cmd = match_ha_command("Allume la lumiere de la chambre ?") + assert cmd is not None and cmd.service == "light.turn_on" diff --git a/tests/unit/test_ha_script_turn_on_t350.py b/tests/unit/test_ha_script_turn_on_t350.py new file mode 100644 index 0000000..d5384be --- /dev/null +++ b/tests/unit/test_ha_script_turn_on_t350.py @@ -0,0 +1,189 @@ +""" +#T350 — Les commandes de volet n'attendent plus la fin du script. + +Le script HA `script.blind_action` contient un `delay` de 26 s dans ses +branches open/close. Or `POST /api/services/script/` ne répond qu'une fois +le script terminé, alors qu'`execute_ha_service` impose un timeout de 10 s : +une commande « ouvre/ferme le volet » ne pouvait jamais recevoir sa réponse. + +Correctif : tout appel `script.` passe désormais par `script.turn_on`, qui +rend la main immédiatement. PIÈGE : `script.turn_on` n'accepte PAS les +paramètres du script à plat — il faut `{"entity_id": "script.", +"variables": {…}}`. Sans `variables`, le script démarre mais aucune branche +`{{ action == '…' }}` ne correspond (pas de `default`), et HA répond quand même +200 : volet immobile, moteur qui annonce le succès. On asserte donc le CORPS du +POST, pas seulement l'URL. +""" + +import aiohttp +import pytest + +import services.execute_service as es +from services.execute_service import match_ha_command + + +# [T356] Depuis que execute_ha_service vérifie l'état de l'entité avant le POST, +# les tests du POST simulent une entité VIVANTE (sinon la lecture d'état ferait +# un vrai GET réseau, bloqué par le garde-fou). read_ha_state est async. +async def _etat_vivant(entity_id: str) -> dict: + return {"entity_id": entity_id, "state": "off", "attributes": {}} + + +class _FakeResp: + def __init__(self, status=200): + self.status = status + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def text(self): + return "" + + +class _FakeSession: + """Enregistre chaque POST HA : (url, payload, timeout).""" + + def __init__(self): + self.calls: list[tuple[str, dict, aiohttp.ClientTimeout]] = [] + + def post(self, url, json=None, headers=None, timeout=None): + self.calls.append((url, json, timeout)) + return _FakeResp(200) + + +@pytest.mark.asyncio +async def test_volet_passe_par_script_turn_on_avec_variables(monkeypatch): + """ + LE TEST CENTRAL : une commande volet produit un POST vers + /api/services/script/turn_on dont le corps contient `variables` avec la + bonne action ET l'entity_id du script. On asserte le corps, pas l'URL. + """ + fake = _FakeSession() + monkeypatch.setattr(es, "_get_ha_session", lambda: fake) + monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _etat_vivant) + + ok, texte = await es.execute_ha_service( + "script.blind_action", "cover.volet_salon", + service_data={"action": "close"}, + ) + + assert ok + assert len(fake.calls) == 1 + url, payload, timeout = fake.calls[0] + assert url.endswith("/api/services/script/turn_on"), url + assert payload["entity_id"] == "script.blind_action" + assert payload["variables"] == {"action": "close"} + assert timeout.total == 10 + + +@pytest.mark.asyncio +async def test_volet_open_passe_aussi_par_script_turn_on(monkeypatch): + """La branche open est couverte par le même mécanisme, avec action 'open'.""" + fake = _FakeSession() + monkeypatch.setattr(es, "_get_ha_session", lambda: fake) + monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _etat_vivant) + + ok, _ = await es.execute_ha_service( + "script.blind_action", "cover.volet_salon", + service_data={"action": "open"}, + ) + + assert ok + url, payload, _ = fake.calls[0] + assert url.endswith("/api/services/script/turn_on") + assert payload["entity_id"] == "script.blind_action" + assert payload["variables"] == {"action": "open"} + + +@pytest.mark.asyncio +async def test_service_ordinaire_inchange_light_turn_off(monkeypatch): + """ + LE TEST JUMEAU (non-régression) : un service ordinaire part exactement + comme avant — même URL, même corps, même timeout. light.turn_off a répondu + en 271 ms le 17/08 sur ce chemin : il ne doit rien changer. + """ + fake = _FakeSession() + monkeypatch.setattr(es, "_get_ha_session", lambda: fake) + monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _etat_vivant) + + ok, texte = await es.execute_ha_service("light.turn_off", "light.living_room") + + assert ok + assert len(fake.calls) == 1 + url, payload, timeout = fake.calls[0] + assert url.endswith("/api/services/light/turn_off"), url + assert payload == {"entity_id": "light.living_room"} + assert timeout.total == 10 + assert "éteinte" in texte + + +@pytest.mark.asyncio +async def test_autre_script_generique_utilise_aussi_turn_on(monkeypatch): + """Tout appel `script.` (pas seulement le volet) passe par turn_on.""" + fake = _FakeSession() + monkeypatch.setattr(es, "_get_ha_session", lambda: fake) + monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _etat_vivant) + + ok, _ = await es.execute_ha_service( + "script.garden_watering", "script.garden_watering", + service_data={"duree": 10}, + ) + + assert ok + url, payload, _ = fake.calls[0] + assert url.endswith("/api/services/script/turn_on") + assert payload["entity_id"] == "script.garden_watering" + assert payload["variables"] == {"duree": 10} + + +# ── Reconnaissance des formes STT réellement dites ─────────────────────────── + +def test_remonte_et_remontre_ouvrent_le_volet(): + """« remonte » et sa transcription « remontre » sont des ordres d'ouverture.""" + for phrase in ("remonte le volet", "remontre le volet", "relève le volet"): + m = match_ha_command(phrase) + assert m is not None, phrase + assert m.service == "script.blind_action", phrase + assert m.service_data == {"action": "open"}, phrase + + +def test_descend_continue_de_fermer(): + """« descend le volet » continue de fermer (non-régression).""" + m = match_ha_command("descend le volet") + assert m is not None + assert m.service == "script.blind_action" + assert m.service_data == {"action": "close"} + + +def test_monte_existant_toujours_reconnu(): + """La reconnaissance existante (« monte », « ouvre ») n'est pas cassée.""" + for phrase in ("monte le volet", "ouvre le volet"): + m = match_ha_command(phrase) + assert m is not None + assert m.service_data == {"action": "open"} + + +# ── Garde-fou : volet sans action refusé, même sur le nouveau chemin ───────── + +@pytest.mark.asyncio +async def test_volet_sans_action_refuse(monkeypatch): + """Le refus documenté (volet appelé sans `action`) tient toujours.""" + fake = _FakeSession() + monkeypatch.setattr(es, "_get_ha_session", lambda: fake) + monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + + ok, texte = await es.execute_ha_service( + "script.blind_action", "cover.volet_salon", + service_data=None, + ) + + assert not ok + assert fake.calls == [], "un volet sans action ne doit produire AUCUN POST" + assert "volet" in texte.lower() diff --git a/tests/unit/test_ha_state_query.py b/tests/unit/test_ha_state_query.py index 23b3c12..b3da26a 100644 --- a/tests/unit/test_ha_state_query.py +++ b/tests/unit/test_ha_state_query.py @@ -4,12 +4,10 @@ from services import ha_state_query as hsq from services.ha_state_query import ( - HAStateQuery, match_ha_state_query, resolve_ha_state_query, ) - # ── Détection : questions d'état vs commandes ── def test_command_imperative_is_not_state_query(): @@ -112,3 +110,95 @@ async def _fake_state(_entity): # ne devrait pas être appelé raise AssertionError("read_ha_state ne doit pas être appelé pour une commande") monkeypatch.setattr(hsq, "read_ha_state", _fake_state) assert await resolve_ha_state_query("allume la lumiere du salon") is None + + +# ── [#T358] Questions générales : le monde, pas l'installation ── + +@pytest.mark.parametrize("phrase", [ + # Phrase exacte mesurée en prod le 17/08 (répondue « Le volet est + # partiellement ouvert. » en 58 ms, deux fois de suite). + "en une phrase, c'est quoi un volet roulant ?", + "c'est quoi un volet roulant ?", + "explique-moi en une phrase comment marche une ampoule LED", + "comment fonctionne une climatisation reversible ?", + "a quoi sert un thermostat ?", + "quelle difference entre un store et un volet ?", +]) +def test_question_generale_nest_pas_une_lecture_detat(phrase): + """Une question de culture générale ne doit jamais lire un état HA.""" + assert match_ha_state_query(phrase) is None + + +@pytest.mark.parametrize("phrase,kind", [ + # Non-régression : les vraies questions d'état passent toujours. + ("le volet est ouvert ?", "volet"), + ("est-ce que la clim est allumee", "climate"), + ("la lumiere du salon est allumee ?", "light"), + ("il fait combien dans le salon ?", "temperature"), + # Article indéfini MAIS adjectif d'état : reste une question d'état. + ("est-ce qu'une lumiere est allumee dans le salon ?", "light"), +]) +def test_vraies_questions_detat_inchangees(phrase, kind): + q = match_ha_state_query(phrase) + assert q is not None and q.kind == kind + + +@pytest.mark.asyncio +async def test_question_generale_ne_lit_aucun_etat(monkeypatch): + """Garde-fou : aucune lecture HA ne part pour une question de définition.""" + async def _fake_state(_entity): + raise AssertionError("read_ha_state ne doit pas être appelé pour une définition") + monkeypatch.setattr(hsq, "read_ha_state", _fake_state) + assert await resolve_ha_state_query("c'est quoi un volet roulant ?") is None + + +# ── [#T364] Ne pas deviner : « combien » seul, pièce sans capteur, mot de domaine ── + +@pytest.mark.parametrize("phrase", [ + # Phrase exacte mesurée en prod le 17/08 (répondue « Il fait 22 degrés dans + # le salon. » en 38 ms), et ses voisines vérifiées généralisables. + "Combien fait 17 fois 24 ?", + "Combien coute un billet de train pour Paris ?", + "Combien d'habitants a la France ?", + "Combien de temps pour cuire un gateau ?", +]) +def test_combien_seul_nest_pas_une_question_de_temperature(phrase): + """« combien » est trop courant en question libre pour valoir marqueur.""" + assert match_ha_state_query(phrase) is None + + +def test_piece_sans_capteur_ne_repond_pas_celle_d_a_cote(): + """ + Une question sur une pièce ne doit jamais rendre le capteur d'une autre. + + [#T371] L'assertion d'origine (#T364) exigeait `None` pour la chambre : + c'était figer un comportement dégradé, car la chambre porte un thermomètre + bien vivant (`sensor.bedroom_temperature`). Elle répond + désormais — avec SON capteur. L'intention du test est conservée et + renforcée : ce qui est interdit, c'est le capteur du salon. + """ + q = match_ha_state_query("il fait combien dans la chambre ?") + assert q is not None + assert q.entity_id == "sensor.bedroom_temperature" + + # La pièce réellement sans capteur, elle, ne reçoit toujours rien. + assert match_ha_state_query("il fait combien dans la buanderie ?") is None + + +def test_nom_de_piece_seul_ne_declenche_pas_la_lumiere(): + """ + Mesuré en prod : « Quel est l'attempérature du salon ? » (STT abîmé) tombait + dans la branche lumière et répondait « La lumière du salon est allumée. ». + """ + assert match_ha_state_query("Quel est l'attemperature du salon ?") is None + + +@pytest.mark.parametrize("phrase,kind", [ + ("il fait combien dans le salon ?", "temperature"), # « combien » + pièce connue + ("quelle temperature dans le salon ?", "temperature"), + ("quelle est la temperature ?", "temperature"), # sans pièce → défaut salon + ("la lumiere du salon est allumee ?", "light"), +]) +def test_non_regression_lectures_detat_legitimes(phrase, kind): + q = match_ha_state_query(phrase) + assert q is not None and q.kind == kind diff --git a/tests/unit/test_ha_tls.py b/tests/unit/test_ha_tls.py index ffc5796..ba8c5cc 100644 --- a/tests/unit/test_ha_tls.py +++ b/tests/unit/test_ha_tls.py @@ -51,7 +51,7 @@ def test_pinning_substitue_le_nom_verifie(monkeypatch): ctx = ha_ssl_context() with patch.object(ssl.SSLContext, "wrap_bio") as parent: - ctx.wrap_bio(MagicMock(), MagicMock(), server_hostname="192.168.1.x") + ctx.wrap_bio(MagicMock(), MagicMock(), server_hostname="192.168.1.10") assert parent.call_args.kwargs["server_hostname"] == "cert.example.fr" @@ -62,9 +62,9 @@ def test_sans_pinning_le_nom_demande_est_respecte(monkeypatch): assert type(ctx) is ssl.SSLContext with patch.object(ssl.SSLContext, "wrap_bio") as parent: - ctx.wrap_bio(MagicMock(), MagicMock(), server_hostname="192.168.1.x") + ctx.wrap_bio(MagicMock(), MagicMock(), server_hostname="192.168.1.10") - assert parent.call_args.kwargs["server_hostname"] == "192.168.1.x" + assert parent.call_args.kwargs["server_hostname"] == "192.168.1.10" def test_requests_verify_suit_la_politique(monkeypatch): diff --git a/tests/unit/test_ha_url_t330.py b/tests/unit/test_ha_url_t330.py index b4ac22b..3359600 100644 --- a/tests/unit/test_ha_url_t330.py +++ b/tests/unit/test_ha_url_t330.py @@ -1,7 +1,7 @@ """ tests/unit/test_ha_url_t330.py — plus aucune URL HA en clair en dur (#T330). -Mesuré le 12/08 : `http://192.168.1.x:8123` était écrit en dur comme valeur +Mesuré le 12/08 : `http://192.168.1.10:8123` était écrit en dur comme valeur de repli à 9 endroits (5 fichiers) — une URL en clair vers un serveur qui n'écoute qu'en HTTPS : un défaut FAUX. Home Assistant refoule le clair sans répondre (`RemoteDisconnected`, cf. #T329), et ces valeurs finissaient @@ -60,19 +60,19 @@ def test_ha_url_en_repli(monkeypatch): def test_hass_url_definie_comportement_inchange(monkeypatch): """Avec HASS_URL définie, les sites se comportent exactement comme avant.""" - monkeypatch.setenv("HASS_URL", "https://192.168.1.x:8123") + monkeypatch.setenv("HASS_URL", "https://192.168.1.10:8123") monkeypatch.setenv("HASS_TOKEN", "jeton-de-test") - assert get_ha_url() == "https://192.168.1.x:8123" + assert get_ha_url() == "https://192.168.1.10:8123" from core.tab5_pusher import Tab5Pusher pusher = Tab5Pusher() - assert pusher.ha_url == "https://192.168.1.x:8123" + assert pusher.ha_url == "https://192.168.1.10:8123" from core.vocal_tools import _ha_credentials - assert _ha_credentials() == ("https://192.168.1.x:8123", "jeton-de-test") + assert _ha_credentials() == ("https://192.168.1.10:8123", "jeton-de-test") from api.routes.ha import _get_ha_credentials - assert _get_ha_credentials() == ("https://192.168.1.x:8123", "jeton-de-test") + assert _get_ha_credentials() == ("https://192.168.1.10:8123", "jeton-de-test") # ── Sans variables : chaque site échoue en nommant la variable ─────────────── @@ -176,8 +176,8 @@ def test_aucune_url_ha_en_dur_dans_le_code_runtime(): if any(part in exclus for part in p.parts): continue for i, ligne in enumerate(p.read_text(encoding="utf-8").splitlines(), 1): - if '"http://192.168.1.x:8123"' in ligne \ - or '"https://192.168.1.x:8123"' in ligne: + if '"http://192.168.1.10:8123"' in ligne \ + or '"https://192.168.1.10:8123"' in ligne: coupables.append(f"{p.relative_to(racine)}:{i}") assert not coupables, ( "URL HA en dur réintroduite dans le code runtime (utiliser " diff --git a/tests/unit/test_ha_weather_query.py b/tests/unit/test_ha_weather_query.py new file mode 100644 index 0000000..60d9db4 --- /dev/null +++ b/tests/unit/test_ha_weather_query.py @@ -0,0 +1,231 @@ +"""Tests météo locale vocale (services/ha_weather_query).""" + +from datetime import date +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from services import ha_weather_query as hwq +from services.ha_weather_query import ( + WeatherHorizon, + match_weather_query, + resolve_weather_query, +) + +# ── Détection : les phrases mesurées en prod sont de la météo ── + +def test_prod_phrase_quel_temps_detecte(): + q = match_weather_query("quel temps fait-il aujourd'hui ?") + assert q is not None + assert q.horizon == WeatherHorizon.TODAY + + +def test_prod_phrase_pleuvoir_demain_detecte(): + q = match_weather_query("est-ce qu'il va pleuvoir demain ?") + assert q is not None + assert q.horizon == WeatherHorizon.TOMORROW + + +def test_variantes_formulations_detectees(): + assert match_weather_query("quel temps il fait") is not None + assert match_weather_query("quel temps il fera") is not None + assert match_weather_query("il va pleuvoir") is not None + assert match_weather_query("il fait combien dehors") is not None + assert match_weather_query("la météo") is not None + + +# ── Non-régression : « il fait combien dans le salon » reste intérieure ── + +def test_question_temperature_interieure_nest_pas_meteo(): + """Une pièce nommée → question de température intérieure (ha_state_query).""" + for phrase in ( + "il fait combien dans le salon ?", + "il fait combien dans la chambre ?", + "il fait combien dans la cuisine ?", + "il fait combien dans le chevet ?", + ): + assert match_weather_query(phrase) is None, phrase + + +def test_phrase_non_meteo_renvoie_none(): + assert match_weather_query("raconte une blague") is None + assert match_weather_query("quelle heure est il") is None + assert match_weather_query("allume la lumière du salon") is None + + +# ── Résolution « maintenant » depuis les attributs simulés ── + +@pytest.mark.asyncio +async def test_resolve_maintenant(monkeypatch): + async def _fake_state(_entity): + return { + "state": "partlycloudy", + "attributes": { + "temperature": 26.2, + "apparent_temperature": 32.6, + "temperature_unit": "°C", + }, + } + monkeypatch.setattr(hwq, "read_ha_state", _fake_state) + reply = await resolve_weather_query("quel temps fait-il") + assert reply is not None + assert "26 degrés" in reply # la température lue est dans la phrase + assert "partiellement nuageux" in reply + # Écart > 3 °C → ressenti ajouté ; aucun mot inventé (pas de location, pas de %) + assert "Ressenti 33 degrés" in reply + assert "localisation" not in reply + + +@pytest.mark.asyncio +async def test_resolve_maintenant_sans_ressenti(monkeypatch): + """Écart apparent ≤ 3 °C → pas de ressenti (phrase courte).""" + async def _fake_state(_entity): + return { + "state": "sunny", + "attributes": {"temperature": 24.0, "apparent_temperature": 25.0}, + } + monkeypatch.setattr(hwq, "read_ha_state", _fake_state) + reply = await resolve_weather_query("il fait combien dehors") + assert reply is not None + assert "24 degrés" in reply + assert "ensoleillé" in reply + assert "Ressenti" not in reply + + +# ── Résolution « demain » depuis un service_response simulé ── + +class _FakeDate(date): + """`date.today()` figé au 17/08/2026 pour rendre « demain » déterministe.""" + + @classmethod + def today(cls): + return cls(2026, 8, 17) + + +def _async_forecast(value): + """Retourne une fonction async renvoyant `value` (monkeypatch de lecture).""" + async def _fake(): + return value + return _fake + + +@pytest.mark.asyncio +async def test_resolve_demain(monkeypatch): + # Prévision au format réel de `weather.get_forecasts?return_response`. + forecast = [ + { + "datetime": "2026-08-17T00:00:00+00:00", + "condition": "cloudy", + "temperature": 27.4, + "templow": 22.4, + "precipitation": 0.0, + "humidity": 90, + }, + { + "datetime": "2026-08-18T00:00:00+00:00", + "condition": "sunny", + "temperature": 28.5, + "templow": 20.2, + "precipitation": 0.1, + "humidity": 95, + }, + ] + monkeypatch.setattr(hwq, "date", _FakeDate) + monkeypatch.setattr(hwq, "_get_daily_forecast", _async_forecast(forecast)) + reply = await resolve_weather_query("est-ce qu'il va pleuvoir demain ?") + assert reply is not None + assert "Demain" in reply + assert "ensoleillé" in reply # condition du 18/08 traduite + assert "28 degrés" in reply # température du 18/08 lue + assert "10% de risques de précipitations" in reply # 0.1 → 10 % + + +@pytest.mark.asyncio +async def test_resolve_demain_absente_renvoie_none(monkeypatch): + """Prévision absente pour le jour demandé → None (cascade continue).""" + monkeypatch.setattr(hwq, "date", _FakeDate) + # Aucune entrée pour le 18/08. + forecast = [ + { + "datetime": "2026-08-17T00:00:00+00:00", + "condition": "cloudy", + "temperature": 27.4, + "templow": 22.4, + }, + ] + monkeypatch.setattr(hwq, "_get_daily_forecast", _async_forecast(forecast)) + assert await resolve_weather_query("quel temps il fera demain ?") is None + + +# ── Lecture en échec → None (pas de valeur de repli) ── + +@pytest.mark.asyncio +async def test_resolve_lecture_en_echec_renvoie_none(monkeypatch): + async def _fake_state(_entity): + return None # HA muet / entité absente + monkeypatch.setattr(hwq, "read_ha_state", _fake_state) + assert await resolve_weather_query("quel temps fait-il ?") is None + + +@pytest.mark.asyncio +async def test_resolve_forecast_en_echec_renvoie_none(monkeypatch): + monkeypatch.setattr(hwq, "date", _FakeDate) + monkeypatch.setattr(hwq, "_get_daily_forecast", _async_forecast(None)) + assert await resolve_weather_query("est-ce qu'il va pleuvoir demain ?") is None + + +@pytest.mark.asyncio +async def test_resolve_non_meteo_renvoie_none(monkeypatch): + async def _fake_state(_entity): + raise AssertionError("read_ha_state ne doit pas être appelé hors météo") + monkeypatch.setattr(hwq, "read_ha_state", _fake_state) + assert await resolve_weather_query("il fait combien dans le salon ?") is None + + +# ── Branchement dans core/vocal_host.handle_discussion ── + +@pytest.mark.asyncio +async def test_handle_discussion_route_meteo_vers_ha_weather(): + """Une demande météo est interceptée AVANT le spécialiste web.""" + from core.vocal_host import handle_discussion + + with patch( + "core.vocal_host._try_zero_llm_ha_weather", + new_callable=AsyncMock, + return_value="Il fait 26 degrés, partiellement nuageux.", + ): + result = await handle_discussion( + user_prompt="quel temps fait-il aujourd'hui ?", + session_id="sess_meteo", + gateway=MagicMock(), + token_tracker=MagicMock(), + fast_path_cache={}, + ) + assert result.routing_type == "discussion_ha_weather" + assert result.agents_used == ["ha_weather", "vocal_host"] + assert result.async_job_id is None + assert "26 degrés" in result.response_text + + +@pytest.mark.asyncio +async def test_handle_discussion_meteo_en_echec_continue_vers_web(): + """Lecture HA en échec → None → la cascade repart vers le spécialiste web.""" + from core.vocal_host import handle_discussion + + with patch( + "core.vocal_host._try_zero_llm_ha_weather", + new_callable=AsyncMock, + return_value=None, + ), patch( + "core.vocal_jobs.run_vocal_specialist", + new_callable=AsyncMock, + return_value="Réponse du spécialiste web.", + ): + result = await handle_discussion( + user_prompt="quel temps fait-il aujourd'hui ?", + session_id="sess_meteo_fail", + gateway=MagicMock(), + token_tracker=MagicMock(), + fast_path_cache={}, + ) + assert result.routing_type == "vocal_host_web" diff --git a/tests/unit/test_hitl_risk.py b/tests/unit/test_hitl_risk.py index d1ada1e..326cd2f 100644 --- a/tests/unit/test_hitl_risk.py +++ b/tests/unit/test_hitl_risk.py @@ -70,6 +70,32 @@ def test_autonomous_session_bypasses_high_risk(): assert eng._hitl_bypass_reason("dreamer_x", "high") is not None +def test_tache_dreamcoder_contourne_le_hitl(): + """ + Les sessions `task_*` sont celles de DreamCoder, jouées sur le backlog à + 3 h du matin. Elles écrivent des fichiers et lancent des commandes : TOUS + leurs plans sont « à risque » par construction. Sans ce bypass, chacune + sortait en `waiting_approval` devant un écran que personne ne regardait — + mesuré le 18/08 sur le Deck : 4 tâches lancées, 4 plans corrects, 4 + rollbacks, 0 ligne de code produite. + """ + eng = _engine(session_id="task_42") + assert eng._hitl_bypass_reason("task_42", "high") is not None + assert eng._hitl_bypass_reason("task_42", "critical") is not None + + +def test_le_prefixe_task_ne_deborde_pas_sur_les_sessions_humaines(): + """ + Garde-fou : le bypass s'évalue sur un PRÉFIXE, jamais sur une sous-chaîne. + Une session interactive dont l'identifiant contient « task » reste soumise + à l'approbation — sinon il suffirait de nommer sa session pour s'en + affranchir. + """ + eng = _engine(session_id="chat_task_1") + assert eng._hitl_bypass_reason("chat_task_1", "high") is None + assert eng._hitl_bypass_reason("gui_session_task_9", "critical") is None + + def test_interactive_high_risk_requires_approval(): """[M5] Une session chat_* à risque n'est PLUS contournée d'office.""" eng = _engine(session_id="chat_abc") diff --git a/tests/unit/test_journal_hygiene.py b/tests/unit/test_journal_hygiene.py new file mode 100644 index 0000000..cdd0773 --- /dev/null +++ b/tests/unit/test_journal_hygiene.py @@ -0,0 +1,191 @@ +""" +test_journal_hygiene.py — Hygiène du journal du Deck. + +Deux défauts de lisibilité du journal, mesurés en prod : + - DÉFAUT 1 : le warning TLS d'opt-out (HA_VERIFY_TLS=false) était répété à + chaque construction de contexte SSL (~644 occurrences en 36 h). Il doit + être émis une seule fois par démarrage, sans changer le comportement TLS. + - DÉFAUT 2 : une HTTPError sans `response` (transport, DNS, timeout…) + journalisait « Erreur HTTP ? : » et perdait l'exception (~78 occurrences + en 36 h). On doit journaliser le type et le message de l'exception. +""" + +from __future__ import annotations + +import logging +import ssl +from unittest.mock import Mock, patch + +import requests + +from core import ha_tls +from core.gemini_native import GeminiNativeProvider, _http_error_detail + +# ────────────────────────────────────────────────────────────────── +# DÉFAUT 1 — warning TLS d'opt-out unique par démarrage +# ────────────────────────────────────────────────────────────────── + +def _env_tls_propre(monkeypatch): + """Isole les variables TLS pour ne pas dépendre de l'environnement réel.""" + for cle in ("HA_VERIFY_TLS", "HA_CA_BUNDLE", "HA_TLS_SERVER_HOSTNAME"): + monkeypatch.delenv(cle, raising=False) + monkeypatch.setenv("HA_VERIFY_TLS", "false") + # Réinitialise le drapeau de module : chaque test part d'un "démarrage" neuf. + ha_tls._warning_optout_affiche = False + + +def test_warning_tls_emise_une_seule_fois_par_demarrage(monkeypatch, caplog): + """Deux contextes opt-out n'émettent pas deux WARNING identiques. + + La première construction le journalise bien ; la seconde, non. + Le comportement TLS, lui, reste identique aux deux appels. + """ + _env_tls_propre(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="core.ha_tls"): + ctx1 = ha_tls.ha_ssl_context() + ctx2 = ha_tls.ha_ssl_context() + + messages = [r.message for r in caplog.records + if "Vérification TLS DÉSACTIVÉE" in r.message] + assert len(messages) == 1, ( + "le warning d'opt-out doit être émis une seule fois par démarrage" + ) + + # Le comportement TLS est inchangé : désactivation effective aux deux appels. + for ctx in (ctx1, ctx2): + assert ctx.verify_mode == ssl.CERT_NONE + assert ctx.check_hostname is False + + +def test_warning_tls_reexiste_au_demarrage_suivant(monkeypatch, caplog): + """Un nouveau module (nouveau démarrage) ré-émet le warning une fois.""" + _env_tls_propre(monkeypatch) + + with caplog.at_level(logging.WARNING, logger="core.ha_tls"): + ha_tls.ha_ssl_context() + + # Simule un redémarrage : le drapeau est remis à zéro. + ha_tls._warning_optout_affiche = False + caplog.clear() + + with caplog.at_level(logging.WARNING, logger="core.ha_tls"): + ha_tls.ha_ssl_context() + + messages = [r.message for r in caplog.records + if "Vérification TLS DÉSACTIVÉE" in r.message] + assert len(messages) == 1 + + +def test_warning_tls_absent_quand_verification_active(monkeypatch, caplog): + """Avec vérification active, aucun warning d'opt-out n'est émis.""" + for cle in ("HA_VERIFY_TLS", "HA_CA_BUNDLE", "HA_TLS_SERVER_HOSTNAME"): + monkeypatch.delenv(cle, raising=False) + ha_tls._warning_optout_affiche = False + + with caplog.at_level(logging.WARNING, logger="core.ha_tls"): + ha_tls.ha_ssl_context() + + messages = [r.message for r in caplog.records + if "Vérification TLS DÉSACTIVÉE" in r.message] + assert not messages + + +# ────────────────────────────────────────────────────────────────── +# DÉFAUT 2 — HTTPError sans réponse journalisée avec type + message +# ────────────────────────────────────────────────────────────────── + +def _http_error_sans_reponse(): + """Construit une HTTPError sans `response` (transport/DNS/timeout).""" + return requests.exceptions.HTTPError("Connection aborted") + + +def _http_error_avec_reponse(): + """Construit une HTTPError avec une réponse 500 et un corps.""" + resp = Mock() + resp.status_code = 500 + resp.text = "Erreur interne du serveur" + err = requests.exceptions.HTTPError("500 Server Error") + err.response = resp + return err + + +def test_http_error_sans_reponse_journalise_type_et_message(caplog): + """Sans réponse, on journalise le type et le message de l'exception.""" + err = _http_error_sans_reponse() + + with caplog.at_level(logging.ERROR, logger="core.gemini_native"): + status, body = _http_error_detail(err) + logging.getLogger("core.gemini_native").error( + f"[GEMINI NATIF] Erreur HTTP {status} : {body}" + ) + + assert status == "HTTPError" + assert body == "Connection aborted" + assert "? " not in status + assert "? " not in body + # Le message journalisé porte l'information de l'exception. + assert "HTTPError" in caplog.text + assert "Connection aborted" in caplog.text + + +def test_http_error_avec_reponse_journalise_code_et_corps(caplog): + """Avec réponse, on journalise toujours le code et le corps (comportement inchangé).""" + err = _http_error_avec_reponse() + + with caplog.at_level(logging.ERROR, logger="core.gemini_native"): + status, body = _http_error_detail(err) + logging.getLogger("core.gemini_native").error( + f"[GEMINI NATIF] Erreur HTTP {status} : {body}" + ) + + assert status == "500" + assert body == "Erreur interne du serveur" + assert "Erreur HTTP 500" in caplog.text + assert "Erreur interne du serveur" in caplog.text + + +@patch("core.gemini_native.requests.post") +def test_generate_erreur_sans_reponse_journalise_exception(mock_post, caplog): + """generate() sur HTTPError sans réponse journalise type + message, jamais « ? ».""" + # Une HTTPError sans `response` (transport/DNS/timeout) doit être + # journalisée avec le type et le message de l'exception. + mock_post.side_effect = requests.exceptions.HTTPError("DNS lookup failed") + provider = GeminiNativeProvider( + api_key="test-key-fake", model="gemini-3.5-flash", enable_explicit_cache=False + ) + + with caplog.at_level(logging.ERROR, logger="core.gemini_native"): + try: + provider.generate("sys", "user") + except requests.exceptions.HTTPError: + pass # l'erreur est re-levée, on vérifie le journal + + assert "HTTPError" in caplog.text + assert "DNS lookup failed" in caplog.text + assert "Erreur HTTP ?" not in caplog.text + assert "Erreur HTTP HTTPError" in caplog.text + + +@patch("core.gemini_native.requests.post") +def test_generate_erreur_http_avec_reponse_inchangee(mock_post, caplog): + """generate() sur HTTPError avec réponse journalise code + corps (inchangé).""" + resp = Mock() + resp.status_code = 429 + resp.text = "Quota dépassé" + err = requests.exceptions.HTTPError("429 Too Many Requests") + err.response = resp + mock_post.side_effect = err + provider = GeminiNativeProvider( + api_key="test-key-fake", model="gemini-3.5-flash", enable_explicit_cache=False + ) + + with caplog.at_level(logging.ERROR, logger="core.gemini_native"): + try: + provider.generate("sys", "user") + except requests.exceptions.HTTPError: + pass + + assert "Erreur HTTP 429" in caplog.text + assert "Quota dépassé" in caplog.text + assert "Erreur HTTP ?" not in caplog.text diff --git a/tests/unit/test_marqueurs_accentues_t383.py b/tests/unit/test_marqueurs_accentues_t383.py new file mode 100644 index 0000000..c89f384 --- /dev/null +++ b/tests/unit/test_marqueurs_accentues_t383.py @@ -0,0 +1,86 @@ +""" +Les marqueurs vocaux doivent matcher le texte RÉEL du STT, qui est accentué. + +Défaut mesuré le 17/08 au soir, après le merge de #T365 (PR #328) : les six +marqueurs du champ horaire avaient été écrits sans accent, alors que +`_normalize_prompt` conserve les accents (`core/vocal_host.py`, la classe de +caractères gardés contient explicitement `àâäéèêëïîôùûüç`). Résultat : +« je commence à travailler » ne matchait AUCUN marqueur et repartait en chat. + +Le défaut a survécu au merge parce que les tests de #T365 écrivent leurs +phrases sans accent (`tests/unit/test_vocal_host.py:331,334`) — ils validaient +donc une entrée que le STT ne produit pas. + +Ce banc ferme le trou dans les deux sens : les formulations accentuées +atteignent le calendrier, et le garde-fou anti-faux-positif de #T365 +(« je commence à comprendre » reste du chat) tient toujours. +""" + +import unicodedata + +import pytest + +from core.vocal_host import VocalIntent, _normalize_prompt, classify_vocal_intent + +# Formulations réelles, telles qu'un STT français les rend : avec accents. +PHRASES_ACCENTUEES_CALENDRIER = [ + "je commence à travailler demain", + "à quelle heure je commence à travailler ?", + "je finis à quelle heure aujourd'hui ?", + "je termine à quelle heure ?", + "est-ce que je suis en congé demain ?", + "j'ai quoi de prévu ce week-end ?", + "quoi de prévu demain ?", +] + + +@pytest.mark.parametrize("phrase", PHRASES_ACCENTUEES_CALENDRIER) +def test_les_formulations_accentuees_atteignent_le_calendrier(phrase): + """Le cas qui échouait : accentué, donc jamais reconnu avant #T383.""" + intent, score = classify_vocal_intent(phrase) + assert intent is VocalIntent.CALENDAR, ( + f"{phrase!r} doit être routée vers le calendrier, obtenu {intent} " + f"(score {score})" + ) + + +@pytest.mark.parametrize( + "phrase", + [ + # Les mêmes sans accent : le STT peut aussi produire du texte plat. + "je commence a travailler demain", + "je finis a quelle heure aujourd'hui ?", + "j'ai quoi de prevu ce week-end ?", + ], +) +def test_les_formulations_sans_accent_marchent_toujours(phrase): + """#T365 ne doit pas régresser : les deux orthographes sont couvertes.""" + intent, _ = classify_vocal_intent(phrase) + assert intent is VocalIntent.CALENDAR, f"{phrase!r} doit rester calendrier" + + +@pytest.mark.parametrize( + "phrase", + [ + # Garde-fou de #T365 : « je commence » seul ne doit pas capter le chat. + "je commence à comprendre la relativité", + "je commence a comprendre la relativite", + ], +) +def test_le_garde_fou_anti_faux_positif_tient(phrase): + """Une phrase de chat qui contient « je commence » reste du chat.""" + intent, _ = classify_vocal_intent(phrase) + assert intent is not VocalIntent.CALENDAR, ( + f"{phrase!r} ne doit PAS partir au calendrier" + ) + + +def test_normalize_prompt_conserve_bien_les_accents(): + """ + Fige la cause racine. Si un jour `_normalize_prompt` se met à dépouiller + les accents, ce test tombe et signale que les doublons de marqueurs + accentués sont devenus inutiles — au lieu de les laisser pourrir. + """ + norm = _normalize_prompt("Je commence à travailler") + assert "à" in norm, "la normalisation ne doit pas dépouiller les accents" + assert norm == unicodedata.normalize("NFKC", norm), "sortie attendue en NFKC" diff --git a/tests/unit/test_model_health_backoff.py b/tests/unit/test_model_health_backoff.py new file mode 100644 index 0000000..0e5c9c1 --- /dev/null +++ b/tests/unit/test_model_health_backoff.py @@ -0,0 +1,325 @@ +""" +Sonde de vivacité — backoff, classement de la cause et alerte-une-fois. + +Motif, mesuré en PROD (pas déduit) : 31/76 modèles éteints par la sonde, avec +`echecs_consecutifs = 112` pour toute la famille Claude et une latence de 3 000 +à 3 900 ms par test. 112 cycles à re-confirmer la même panne, c'est ~4,7 jours +de re-tests inutiles (24×/jour, ~3 s + vrais tokens à chaque fois). Trois des +quatre familles de pannes ne guérissent jamais toutes seules (crédit épuisé, +workspace non trusté, binaire absent) : seul un humain peut les réparer. + +Ce fichier verrouille : + 1. le backoff : un modèle éteint par la sonde n'est PAS re-testé à chaque + cycle, mais l'est encore régulièrement — et un succès le réactive ; + 2. le classement : les trois messages d'erreur réels ci-dessus sont + « actionnable », un timeout réseau ne l'est pas ; + 3. l'alerte-une-fois : une cause actionnable produit UN signal, pas N ; + 4. les garde-fous inchangés : panne globale et plancher de modèles actifs. +""" + +import pytest + +import core.model_health_probe as sonde +from core import runtime_db + + +@pytest.fixture(autouse=True) +def _base_temporaire(tmp_path, monkeypatch): + """Base réelle au schéma du dépôt, et sonde en mode « action autorisée ».""" + runtime_db.override_db_path(str(tmp_path / "sante.db")) + runtime_db.get_connection().close() # déclenche _init_schema + monkeypatch.setenv("MODEL_HEALTH_PROBE", "true") + monkeypatch.setenv("MODEL_HEALTH_AUTO_DISABLE", "true") + + +class _FauxCatalogue: + """Catalogue en mémoire : `get_active_models` + `set_model_status`.""" + + def __init__(self, actifs): + self.statuts = dict.fromkeys(actifs, "active") + self.appels = [] + + def get_active_models(self, provider_id=None): + return [{"id": m} for m, s in self.statuts.items() if s == "active"] + + def set_model_status(self, model_id, status, *, revendique_par_sonde=False): + self.statuts[model_id] = status + self.appels.append((model_id, status, revendique_par_sonde)) + if revendique_par_sonde: + sonde._ecrire_sante( + model_id, desactive_par_sonde=1 if status == "inactive" else 0, + ) + else: + sonde._ecrire_sante(model_id, desactive_par_sonde=0) + return True + + +def _brancher(monkeypatch, catalogue, muets: set, erreur="HTTP 401 invalid_api_key"): + """Branche le catalogue ; `muets` échouent avec `erreur`, les autres répondent.""" + monkeypatch.setattr("core.models_db.get_active_models", catalogue.get_active_models) + monkeypatch.setattr("core.models_db.set_model_status", catalogue.set_model_status) + + async def _test(model_id, gateway=None): + if model_id in muets: + return False, erreur, 3000.0 + return True, "", 30.0 + + monkeypatch.setattr(sonde, "tester_modele", _test) + + +def _horloge_avancante(monkeypatch, depart: float): + """Horloge pilotée : `_maintenant()` renvoie une valeur que l'on avance.""" + etat = {"t": depart} + monkeypatch.setattr(sonde, "_maintenant", lambda: etat["t"]) + + def avancer(secondes: float) -> None: + etat["t"] += secondes + + return avancer + + +# ── Test central : backoff puis réactivation ──────────────────────────────── + +@pytest.mark.asyncio +async def test_modele_a_112_echecs_reporte_puis_reactivable(monkeypatch): + """ + Le cœur du backoff : un modèle à 112 échecs consécutifs (cas mesuré en prod) + n'est PAS re-testé à chaque cycle, mais l'est encore régulièrement — et un + succès le réactive immédiatement. + """ + catalogue = _FauxCatalogue([f"m{i}" for i in range(10)]) + erreur_credit = ( + "Your credit balance is too low to access the Anthropic API. " + "Please go to Plans & Billing to upgrade or purchase credits." + ) + _brancher(monkeypatch, catalogue, muets={"m0"}, erreur=erreur_credit) + + # m0 : 112 échecs consécutifs, déjà éteint par la sonde (état prod mesuré). + base = 1_000_000.0 + sonde._ecrire_sante( + "m0", dernier_test=base, echecs_consecutifs=112, succes_consecutifs=0, + desactive_par_sonde=1, derniere_erreur=erreur_credit, latence_ms=3000.0, + ) + catalogue.set_model_status("m0", "inactive", revendique_par_sonde=True) + avancer = _horloge_avancante(monkeypatch, base) + + # Cycle suivant, une heure plus tard : m0 est REPORTÉ (backoff), pas re-testé. + avancer(sonde.INTERVALLE_DEFAUT_S) + cr = await sonde.executer_cycle() + assert cr["reportes_backoff"] >= 1 + # Seuls les 9 modèles actifs ont été testés : m0 (éteint) a été sauté. + assert cr["testes"] == 9, "un modèle à backoff ne doit pas être re-testé ce cycle" + assert cr["morts"] == 0 + + # Jusqu'au plafond : m0 est bien re-testé (et échoue encore). + avancer(sonde.PLAFOND_BACKOFF_S) + cr = await sonde.executer_cycle() + assert cr["morts"] >= 1, "après le plafond, le modèle doit être re-testé" + assert sonde.lire_sante("m0")["echecs_consecutifs"] == 113 + + # m0 répond de nouveau : réactivé immédiatement, sans intervention manuelle. + _brancher(monkeypatch, catalogue, muets=set()) + avancer(sonde.PLAFOND_BACKOFF_S) + cr = await sonde.executer_cycle() + assert cr["reactives"] == ["m0"] + assert catalogue.statuts["m0"] == "active" + assert sonde.lire_sante("m0")["desactive_par_sonde"] == 0 + + +@pytest.mark.asyncio +async def test_modele_eteint_est_reporte_mais_pas_oublie(monkeypatch): + """ + Un modèle éteint par la sonde qui n'a pas encore atteint son intervalle de + backoff est reporté au cycle suivant, mais reste dans la liste des éteints : + il n'est pas perdu et sera re-testé quand l'intervalle sera écoulé. + """ + catalogue = _FauxCatalogue([f"m{i}" for i in range(10)]) + _brancher(monkeypatch, catalogue, muets={"m0"}) + base = 2_000_000.0 + avancer = _horloge_avancante(monkeypatch, base) + # m0 : 4 échecs (intervalle de backoff = 2 h), déjà éteint par la sonde. + sonde._ecrire_sante( + "m0", dernier_test=base, echecs_consecutifs=4, succes_consecutifs=0, + desactive_par_sonde=1, derniere_erreur="HTTP 401 invalid_api_key", + latence_ms=3000.0, + ) + catalogue.set_model_status("m0", "inactive", revendique_par_sonde=True) + + # 1 h plus tard (moins que l'intervalle de 2 h) : m0 est reporté. + avancer(sonde.INTERVALLE_DEFAUT_S) + cr = await sonde.executer_cycle() + assert cr["testes"] == 9, "m0 (éteint) n'a pas été re-testé ce cycle" + assert cr["reportes_backoff"] >= 1 + # Toujours dans la liste des éteints par la sonde : il sera re-testé. + assert "m0" in sonde.modeles_eteints_par_la_sonde() + + +# ── Classement de la cause ────────────────────────────────────────────────── + +@pytest.mark.parametrize( + "erreur", + [ + # Crédit épuisé — reproduit en direct sur api.anthropic.com. + "Your credit balance is too low to access the Anthropic API. Please go " + "to Plans & Billing to upgrade or purchase credits.", + # Workspace non trusté — Claude CLI. + 'Claude CLI error (1): ... this workspace has not been trusted. Run ' + 'Claude Code interactively here once and accept the trust dialog, or ' + 'set projects["/opt/vromvrom-engine"].hasTrustDialogAccepted: true.', + # Binaire CLI absent — Gemini *-cli. + "[Errno 2] No such file or directory: '/usr/local/bin/gemini-cli'", + ], +) +def test_erreurs_reelles_actionnables(erreur): + """Les trois messages d'erreur RÉELS mesurés sont classés « actionnable ».""" + assert sonde.classer_erreur(erreur) == "actionnable" + + +def test_timeout_reseau_est_transitoire(): + """Une panne réseau passagère — la seule qui guérit toute seule — n'est PAS + classée actionnable : elle mérite d'être retentée vite.""" + for erreur in ( + "TimeoutError: timed out after 20 seconds", + "ConnectionError: [Errno -3] Temporary failure in name resolution", + "cerise inconnue qui ne ressemble à rien de connu", + "", + None, + ): + assert sonde.classer_erreur(erreur) == "transitoire", erreur + + +def test_intervalle_backoff_progression_et_plafond(): + """Progression géométrique depuis l'heure, plafonnée à une journée.""" + assert sonde.intervalle_backoff(sonde.SEUIL_ECHECS) == sonde.INTERVALLE_DEFAUT_S + assert sonde.intervalle_backoff(4) == 2 * sonde.INTERVALLE_DEFAUT_S + assert sonde.intervalle_backoff(5) == 4 * sonde.INTERVALLE_DEFAUT_S + # 112 échecs (cas prod) : plafonné à une journée, pas à 2^109 heures. + assert sonde.intervalle_backoff(112) == sonde.PLAFOND_BACKOFF_S + assert sonde.PLAFOND_BACKOFF_S == 24 * 3600 + + +# ── Alerte-une-fois ───────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_cause_actionnable_signalee_une_fois(monkeypatch, caplog): + """ + Une cause actionnable produit UN message clair, pas un par cycle : le + marqueur `cause_actionnable_signalee` est posé à la première détection et + le message n'est pas re-loggué tant que la cause ne change pas. + """ + import logging + + caplog.set_level(logging.WARNING, logger="core.model_health_probe") + catalogue = _FauxCatalogue([f"m{i}" for i in range(10)]) + erreur_credit = "Your credit balance is too low to access the Anthropic API." + _brancher(monkeypatch, catalogue, muets={"m0"}, erreur=erreur_credit) + avancer = _horloge_avancante(monkeypatch, 3_000_000.0) + + # m0 actif, en panne actionnable (crédit). + avancer(sonde.INTERVALLE_DEFAUT_S) + await sonde.executer_cycle() + assert sonde.lire_sante("m0")[sonde._COLONNE_ALERTE] == "credit" + nb_credit = sum("ACTIONNABLE (credit)" in r.message for r in caplog.records) + assert nb_credit == 1 + + # Cycles suivants, même cause : AUCUN nouveau message. + avancer(sonde.INTERVALLE_DEFAUT_S) + await sonde.executer_cycle() + avancer(sonde.INTERVALLE_DEFAUT_S) + await sonde.executer_cycle() + nb_credit = sum("ACTIONNABLE (credit)" in r.message for r in caplog.records) + assert nb_credit == 1, "la même cause ne doit pas être re-signalée à chaque cycle" + + +@pytest.mark.asyncio +async def test_cause_qui_change_est_re_signalee(monkeypatch, caplog): + """ + Quand la cause actionnable change (crédit → binaire absent), un nouveau + message est émis — c'est une information nouvelle pour l'humain. + """ + import logging + + caplog.set_level(logging.WARNING, logger="core.model_health_probe") + catalogue = _FauxCatalogue([f"m{i}" for i in range(10)]) + _brancher( + monkeypatch, catalogue, muets={"m0"}, + erreur="Your credit balance is too low to access the Anthropic API.", + ) + avancer = _horloge_avancante(monkeypatch, 4_000_000.0) + avancer(sonde.INTERVALLE_DEFAUT_S) + await sonde.executer_cycle() + assert sonde.lire_sante("m0")[sonde._COLONNE_ALERTE] == "credit" + + # La cause change : binaire absent. + _brancher( + monkeypatch, catalogue, muets={"m0"}, + erreur="[Errno 2] No such file or directory: '/usr/local/bin/gemini-cli'", + ) + avancer(sonde.INTERVALLE_DEFAUT_S) + await sonde.executer_cycle() + assert sonde.lire_sante("m0")[sonde._COLONNE_ALERTE] == "binaire" + nb_binaire = sum("ACTIONNABLE (binaire)" in r.message for r in caplog.records) + assert nb_binaire == 1 + + +@pytest.mark.asyncio +async def test_succes_efface_le_marqueur_d_alerte(monkeypatch): + """ + Un succès répare la cause : le marqueur est effacé, donc une prochaine panne + actionnable (même cause) sera re-signalée et non avalée. + """ + catalogue = _FauxCatalogue([f"m{i}" for i in range(10)]) + erreur_credit = "Your credit balance is too low to access the Anthropic API." + _brancher(monkeypatch, catalogue, muets={"m0"}, erreur=erreur_credit) + avancer = _horloge_avancante(monkeypatch, 5_000_000.0) + avancer(sonde.INTERVALLE_DEFAUT_S) + await sonde.executer_cycle() + assert sonde.lire_sante("m0")[sonde._COLONNE_ALERTE] == "credit" + + # m0 répond : marqueur effacé. + _brancher(monkeypatch, catalogue, muets=set()) + avancer(sonde.INTERVALLE_DEFAUT_S) + await sonde.executer_cycle() + assert sonde.lire_sante("m0")[sonde._COLONNE_ALERTE] is None + + +# ── Garde-fous inchangés ──────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_panne_globale_ne_desactive_toujours_rien(monkeypatch): + """ + Garde-fou n°2 (inchangé) : quand >80 % du cycle échoue, la cause est locale + (réseau, DNS, FAI) — on ne désactive RIEN. + """ + modeles = [f"m{i}" for i in range(10)] + catalogue = _FauxCatalogue(modeles) + _brancher(monkeypatch, catalogue, muets=set(modeles)) + + avancer = _horloge_avancante(monkeypatch, 6_000_000.0) + for _ in range(sonde.SEUIL_ECHECS + 2): + avancer(sonde.INTERVALLE_DEFAUT_S) + cr = await sonde.executer_cycle() + + assert cr["gele"] is True + assert cr["desactives"] == [] + assert all(statut == "active" for statut in catalogue.statuts.values()) + + +@pytest.mark.asyncio +async def test_plancher_de_modeles_actifs_tient_toujours(monkeypatch): + """ + Garde-fou n°3 (inchangé) : la sonde ne descend jamais sous + MIN_MODELES_ACTIFS, même si un modèle de plus tombe en panne. + """ + modeles = [f"m{i}" for i in range(6)] + catalogue = _FauxCatalogue(modeles) + _brancher(monkeypatch, catalogue, muets={"m0"}) + + avancer = _horloge_avancante(monkeypatch, 7_000_000.0) + for _ in range(sonde.SEUIL_ECHECS + 1): + avancer(sonde.INTERVALLE_DEFAUT_S) + cr = await sonde.executer_cycle() + + actifs = sum(1 for s in catalogue.statuts.values() if s == "active") + assert actifs >= sonde.MIN_MODELES_ACTIFS + assert cr["desactives"] == [], "le plancher n'a pas retenu l'extinction" diff --git a/tests/unit/test_model_health_probe_t333.py b/tests/unit/test_model_health_probe_t333.py index 5f5371f..9995a80 100644 --- a/tests/unit/test_model_health_probe_t333.py +++ b/tests/unit/test_model_health_probe_t333.py @@ -108,7 +108,12 @@ async def test_un_succes_remet_le_compteur_a_zero(monkeypatch): @pytest.mark.asyncio async def test_reactivation_quand_le_modele_repond(monkeypatch): - """Cycle complet : éteint, puis rallumé de lui-même au retour du service.""" + """ + Cycle complet : éteint, puis rallumé de lui-même au retour du service. + Depuis l'ajout du backoff, un modèle éteint n'est plus re-testé à chaque + cycle : on avance l'horloge au-delà du plafond pour prouver que le re-test a + bien lieu et que le succès réactive le modèle. + """ catalogue = _FauxCatalogue([f"m{i}" for i in range(10)]) _brancher(monkeypatch, catalogue, muets={"m0"}) for _ in range(sonde.SEUIL_ECHECS): @@ -116,6 +121,9 @@ async def test_reactivation_quand_le_modele_repond(monkeypatch): assert catalogue.statuts["m0"] == "inactive" _brancher(monkeypatch, catalogue, muets=set()) + # Simule le temps écoulé jusqu'au re-test (plafond du backoff). + base = sonde._maintenant() + monkeypatch.setattr(sonde, "_maintenant", lambda: base + sonde.PLAFOND_BACKOFF_S + 1) cr = await sonde.executer_cycle() assert cr["reactives"] == ["m0"] diff --git a/tests/unit/test_ollama_deck.py b/tests/unit/test_ollama_deck.py index 8cdafc2..77913d5 100644 --- a/tests/unit/test_ollama_deck.py +++ b/tests/unit/test_ollama_deck.py @@ -49,15 +49,15 @@ class TestOllamaDeckProvider: def test_init_url_defaut(self, ollama_provider): """Vérifie que l'URL par défaut pointe bien vers le Deck (IP Ethernet).""" - assert "${DECK_HOST:-192.168.1.x}" in ollama_provider.base_url + assert "192.168.1.10" in ollama_provider.base_url assert "11434" in ollama_provider.base_url assert ollama_provider.model_name == "phi3:mini" def test_init_url_custom(self): """Vérifie qu'on peut surcharger l'hôte et le modèle.""" from core.llm_gateway import OllamaDeckProvider - p = OllamaDeckProvider(host="${DECK_HOST_WIFI:-192.168.1.x}", port=11434, model_name="gemma2:2b") - assert "${DECK_HOST_WIFI:-192.168.1.x}" in p.base_url + p = OllamaDeckProvider(host="192.168.1.10", port=11434, model_name="gemma2:2b") + assert "192.168.1.10" in p.base_url assert p.model_name == "gemma2:2b" def test_ping_available_deck_joignable(self, ollama_provider, mock_tags_ok): @@ -78,13 +78,13 @@ def test_ping_available_basculement_wifi(self): import requests from core.llm_gateway import OllamaDeckProvider - p = OllamaDeckProvider(host="${DECK_HOST:-192.168.1.x}") + p = OllamaDeckProvider(host="192.168.1.10") mock_tags_wifi = MagicMock() mock_tags_wifi.status_code = 200 def mock_get_side_effect(url, **kwargs): - if "${DECK_HOST:-192.168.1.x}" in url: + if "192.168.1.10" in url: raise requests.exceptions.ConnectTimeout return mock_tags_wifi # Wi-Fi répond @@ -93,7 +93,7 @@ def mock_get_side_effect(url, **kwargs): assert result is True # Vérifier que l'IP a bien basculé vers le Wi-Fi - assert "${DECK_HOST_WIFI:-192.168.1.x}" in p.base_url + assert "192.168.1.10" in p.base_url def test_generate_reponse_ok(self, ollama_provider, mock_response_ok): """Test generate() avec une réponse Ollama valide.""" diff --git a/tests/unit/test_openai_compat_max_tokens.py b/tests/unit/test_openai_compat_max_tokens.py new file mode 100644 index 0000000..f888160 --- /dev/null +++ b/tests/unit/test_openai_compat_max_tokens.py @@ -0,0 +1,353 @@ +""" +test_openai_compat_max_tokens.py — Plafond de sortie par défaut (#T316). + +Mesuré en prod le 16/08 : sur `moteur_runtime.db`, six lignes de `token_usage` +portent EXACTEMENT 40000 `completion_tokens`, toutes sur `gemma-4-31b` (Cerebras). +L'écart de temps avec l'appel précédent (22 s / 14 s / 19 s / 57 s / 19 s) prouve +que le modèle a VRAIMENT produit 40000 tokens : il est parti en boucle et n'a été +arrêté que par le plafond par défaut du provider. Le moteur, lui, n'envoie aucun +`max_tokens` aux providers OpenAI-compatibles (core/llm_gateway.py n'en pose +jamais) : rien ne borne la génération. + +On pose donc un plafond de sortie PAR DÉFAUT sur les trois méthodes de +`core/openai_compat_provider.py`, dérivé du `context_output` du catalogue +(core.models_db.get_model), avec un repli configurable par la variable +d'environnement `MOTEUR_MAX_OUTPUT_TOKENS` pour les modèles absents du catalogue +ou à `context_output` nul. + +Règles vérifiées ici : + - SANS `max_tokens` fourni → le payload CONTIENT `max_tokens`, égal au + `context_output` du catalogue pour ce modèle. + - AVEC `max_tokens=30000` → le payload porte bien 30000, jamais le défaut + (le proxy /v1 relaie le `max_tokens` de son client : on ne l'écrase pas). + - Les trois méthodes sont couvertes : generate, generate_async, streaming. + - Modèle ABSENT du catalogue → le repli s'applique, aucune exception. + +Aucun appel réseau réel : le payload est capturé via un double de session HTTP. +""" + +import asyncio +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +# Ajout du répertoire parent au PYTHONPATH +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import core.openai_compat_provider as oacp +from core.openai_compat_provider import OpenAICompatibleProvider + +# Modèle du catalogue : `context_output` = 4096 (miroir de gemma-4-31b-cerebras). +_MODELE_CATALOGUE = "gemma-4-31b-cerebras" +_CONTEXT_OUTPUT = 4096 +_MODELE_INCONNU = "modele-absente-du-catalogue" + + +def _provider(model: str = _MODELE_CATALOGUE) -> OpenAICompatibleProvider: + """Provider OpenAI-compatible de test, sans clé ni appel réseau réel.""" + return OpenAICompatibleProvider( + provider_name="TestProvider", + base_url="http://fake.local/v1/chat/completions", + api_key="cle-test-fake", + model=model, + ) + + +def _reponse_ok(): + """Réponse HTTP factice : un message texte + un usage minimal.""" + resp = MagicMock() + resp.status_code = 200 + resp.json.return_value = { + "choices": [{"message": {"content": "Bonjour"}}], + "usage": {"prompt_tokens": 5, "completion_tokens": 3}, + } + return resp + + +# ────────────────────────────────────────────────────────────────── +# Tests : generate() +# ────────────────────────────────────────────────────────────────── + +class TestGenerateDefaut: + """generate() : plafond par défaut dérivé du catalogue.""" + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + @patch("core.models_db.get_model") + def test_sans_max_tokens_utilise_context_output_catalogue( + self, mock_get_model, mock_get_session + ): + """SANS max_tokens → le payload porte le context_output du catalogue.""" + mock_get_model.return_value = {"context_output": _CONTEXT_OUTPUT} + mock_get_session.return_value.post.return_value = _reponse_ok() + + provider = _provider() + provider.generate("", "Salut") + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == _CONTEXT_OUTPUT + mock_get_model.assert_called_once_with(_MODELE_CATALOGUE) + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + @patch("core.models_db.get_model") + def test_avec_max_tokens_ne_ecrase_pas(self, mock_get_model, mock_get_session): + """AVEC max_tokens=30000 → le payload porte 30000, pas le défaut.""" + mock_get_model.return_value = {"context_output": _CONTEXT_OUTPUT} + mock_get_session.return_value.post.return_value = _reponse_ok() + + provider = _provider() + provider.generate("", "Salut", max_tokens=30000) + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == 30000 + # Le catalogue ne doit PAS être consulté quand l'appelant fournit max_tokens. + mock_get_model.assert_not_called() + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + @patch("core.models_db.get_model") + def test_modele_absent_du_catalogue_utilise_repli( + self, mock_get_model, mock_get_session + ): + """Modèle absent du catalogue → repli MOTEUR_MAX_OUTPUT_TOKENS, sans exception.""" + mock_get_model.return_value = None + mock_get_session.return_value.post.return_value = _reponse_ok() + with patch.dict(os.environ, {"MOTEUR_MAX_OUTPUT_TOKENS": "1234"}, clear=False): + provider = _provider(_MODELE_INCONNU) + provider.generate("", "Salut") + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == 1234 + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + @patch("core.models_db.get_model") + def test_repli_par_defaut_sans_env(self, mock_get_model, mock_get_session): + """Sans env ni catalogue → repli sur _DEFAULT_MAX_OUTPUT_TOKENS.""" + mock_get_model.return_value = None + mock_get_session.return_value.post.return_value = _reponse_ok() + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("MOTEUR_MAX_OUTPUT_TOKENS", None) + provider = _provider(_MODELE_INCONNU) + provider.generate("", "Salut") + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == oacp._DEFAULT_MAX_OUTPUT_TOKENS + + +# ────────────────────────────────────────────────────────────────── +# Tests : generate_async() +# ────────────────────────────────────────────────────────────────── + +class TestGenerateAsyncDefaut: + """generate_async() : même plafond par défaut que generate().""" + + @patch("core.openai_compat_provider.SharedAsyncHTTPPool.get_client") + @patch("core.models_db.get_model") + def test_sans_max_tokens_utilise_context_output_catalogue( + self, mock_get_model, mock_get_client + ): + """SANS max_tokens → le payload async porte le context_output du catalogue.""" + mock_get_model.return_value = {"context_output": _CONTEXT_OUTPUT} + mock_get_client.return_value.post = AsyncMock(return_value=_reponse_ok()) + + async def _run(): + provider = _provider() + await provider.generate_async("", "Salut") + + asyncio.run(_run()) + payload = mock_get_client.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == _CONTEXT_OUTPUT + + @patch("core.openai_compat_provider.SharedAsyncHTTPPool.get_client") + @patch("core.models_db.get_model") + def test_avec_max_tokens_ne_ecrase_pas(self, mock_get_model, mock_get_client): + """AVEC max_tokens=30000 → le payload async porte 30000, pas le défaut.""" + mock_get_model.return_value = {"context_output": _CONTEXT_OUTPUT} + mock_get_client.return_value.post = AsyncMock(return_value=_reponse_ok()) + + async def _run(): + provider = _provider() + await provider.generate_async("", "Salut", max_tokens=30000) + + asyncio.run(_run()) + payload = mock_get_client.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == 30000 + mock_get_model.assert_not_called() + + @patch("core.openai_compat_provider.SharedAsyncHTTPPool.get_client") + @patch("core.models_db.get_model") + def test_modele_absent_du_catalogue_utilise_repli( + self, mock_get_model, mock_get_client + ): + """Modèle absent du catalogue → repli env, sans exception, en async.""" + mock_get_model.return_value = None + mock_get_client.return_value.post = AsyncMock(return_value=_reponse_ok()) + + async def _run(): + with patch.dict(os.environ, {"MOTEUR_MAX_OUTPUT_TOKENS": "4321"}, clear=False): + provider = _provider(_MODELE_INCONNU) + await provider.generate_async("", "Salut") + + asyncio.run(_run()) + payload = mock_get_client.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == 4321 + + +# ────────────────────────────────────────────────────────────────── +# Tests : generate_structured() +# ────────────────────────────────────────────────────────────────── + +class TestGenerateStructuredDefaut: + """generate_structured() : même plafond par défaut.""" + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + @patch("core.models_db.get_model") + def test_sans_max_tokens_utilise_context_output_catalogue( + self, mock_get_model, mock_get_session + ): + """SANS max_tokens → le payload structuré porte le context_output.""" + mock_get_model.return_value = {"context_output": _CONTEXT_OUTPUT} + resp = _reponse_ok() + resp.json.return_value = { + "choices": [{"message": {"content": '{"ok": true}'}}], + "usage": {"prompt_tokens": 5, "completion_tokens": 3}, + } + mock_get_session.return_value.post.return_value = resp + + provider = _provider() + provider.generate_structured("", "Salut", {"type": "object"}) + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == _CONTEXT_OUTPUT + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + @patch("core.models_db.get_model") + def test_avec_max_tokens_ne_ecrase_pas(self, mock_get_model, mock_get_session): + """AVEC max_tokens=30000 → le payload structuré porte 30000.""" + mock_get_model.return_value = {"context_output": _CONTEXT_OUTPUT} + resp = _reponse_ok() + resp.json.return_value = { + "choices": [{"message": {"content": '{"ok": true}'}}], + "usage": {"prompt_tokens": 5, "completion_tokens": 3}, + } + mock_get_session.return_value.post.return_value = resp + + provider = _provider() + provider.generate_structured("", "Salut", {"type": "object"}, max_tokens=30000) + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == 30000 + mock_get_model.assert_not_called() + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + @patch("core.models_db.get_model") + def test_modele_absent_du_catalogue_utilise_repli( + self, mock_get_model, mock_get_session + ): + """Modèle absent du catalogue → repli env, sans exception, en structuré.""" + mock_get_model.return_value = None + resp = _reponse_ok() + resp.json.return_value = { + "choices": [{"message": {"content": '{"ok": true}'}}], + "usage": {"prompt_tokens": 5, "completion_tokens": 3}, + } + mock_get_session.return_value.post.return_value = resp + + with patch.dict(os.environ, {"MOTEUR_MAX_OUTPUT_TOKENS": "999"}, clear=False): + provider = _provider(_MODELE_INCONNU) + provider.generate_structured("", "Salut", {"type": "object"}) + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == 999 + + +# ────────────────────────────────────────────────────────────────── +# Tests : generate_stream() (chemin streaming) +# ────────────────────────────────────────────────────────────────── + +class TestGenerateStreamDefaut: + """generate_stream() : même plafond par défaut sur le payload stream.""" + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + @patch("core.models_db.get_model") + def test_sans_max_tokens_utilise_context_output_catalogue( + self, mock_get_model, mock_get_session + ): + """SANS max_tokens → le payload stream porte le context_output.""" + mock_get_model.return_value = {"context_output": _CONTEXT_OUTPUT} + # Flux SSE factice : un chunk de contenu puis [DONE]. + resp = MagicMock() + resp.status_code = 200 + resp.iter_lines.return_value = [ + 'data: {"choices": [{"delta": {"content": "Bon"}}]}', + "data: [DONE]", + ] + mock_get_session.return_value.post.return_value = resp + + provider = _provider() + list(provider.generate_stream("", "Salut")) + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + assert payload["stream"] is True + assert payload["max_tokens"] == _CONTEXT_OUTPUT + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + @patch("core.models_db.get_model") + def test_avec_max_tokens_ne_ecrase_pas(self, mock_get_model, mock_get_session): + """AVEC max_tokens=30000 → le payload stream porte 30000.""" + mock_get_model.return_value = {"context_output": _CONTEXT_OUTPUT} + resp = MagicMock() + resp.status_code = 200 + resp.iter_lines.return_value = [ + 'data: {"choices": [{"delta": {"content": "Bon"}}]}', + "data: [DONE]", + ] + mock_get_session.return_value.post.return_value = resp + + provider = _provider() + list(provider.generate_stream("", "Salut", max_tokens=30000)) + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == 30000 + mock_get_model.assert_not_called() + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + @patch("core.models_db.get_model") + def test_modele_absent_du_catalogue_utilise_repli( + self, mock_get_model, mock_get_session + ): + """Modèle absent du catalogue → repli env, sans exception, en stream.""" + mock_get_model.return_value = None + resp = MagicMock() + resp.status_code = 200 + resp.iter_lines.return_value = [ + 'data: {"choices": [{"delta": {"content": "Bon"}}]}', + "data: [DONE]", + ] + mock_get_session.return_value.post.return_value = resp + + with patch.dict(os.environ, {"MOTEUR_MAX_OUTPUT_TOKENS": "777"}, clear=False): + provider = _provider(_MODELE_INCONNU) + list(provider.generate_stream("", "Salut")) + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == 777 + + +# ────────────────────────────────────────────────────────────────── +# Tests : le repli ne s'applique jamais quand context_output est nul +# ────────────────────────────────────────────────────────────────── + +class TestContextOutputNul: + """context_output nul → repli, pas de plafond invalide.""" + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + @patch("core.models_db.get_model") + def test_context_output_nul_utilise_repli(self, mock_get_model, mock_get_session): + """context_output nul → repli MOTEUR_MAX_OUTPUT_TOKENS.""" + mock_get_model.return_value = {"context_output": None} + mock_get_session.return_value.post.return_value = _reponse_ok() + with patch.dict(os.environ, {"MOTEUR_MAX_OUTPUT_TOKENS": "2048"}, clear=False): + provider = _provider() + provider.generate("", "Salut") + + payload = mock_get_session.return_value.post.call_args.kwargs["json"] + assert payload["max_tokens"] == 2048 diff --git a/tests/unit/test_openai_proxy_streaming.py b/tests/unit/test_openai_proxy_streaming.py index 4cd59b0..e1521a9 100644 --- a/tests/unit/test_openai_proxy_streaming.py +++ b/tests/unit/test_openai_proxy_streaming.py @@ -216,14 +216,19 @@ def test_usage_du_generateur_propage_dans_le_dernier_chunk(client): # Chemin non-streaming et pont threadpool # ────────────────────────────────────────────────────────────────── -def test_non_streaming_rend_exactement_le_meme_json_qu_avant(client): - """Le chemin `stream=false` est resté identique : même forme JSON, même - comptage approximatif, generate_stream jamais appelé.""" +def test_non_streaming_rend_la_meme_enveloppe_json(client): + """Le chemin `stream=false` garde sa forme JSON et n'appelle jamais + generate_stream. + + [#T357] Seul le champ `usage` a changé : il portait un comptage de MOTS + présenté comme des tokens, il est désormais absent quand le provider ne + remonte pas de comptage réel (couvert par + tests/unit/test_openai_proxy_usage_t357.py).""" reponse = client.post("/v1/chat/completions", json=_requete(stream=False)) assert reponse.status_code == 200 corps = reponse.json() - assert set(corps) == {"id", "object", "created", "model", "choices", "usage"} + assert set(corps) == {"id", "object", "created", "model", "choices"} assert corps["object"] == "chat.completion" assert corps["model"] == "modele-a" choix = corps["choices"][0] @@ -231,9 +236,6 @@ def test_non_streaming_rend_exactement_le_meme_json_qu_avant(client): assert choix["index"] == 0 assert choix["finish_reason"] == "stop" assert choix["message"] == {"role": "assistant", "content": "réponse du modèle"} - assert corps["usage"]["total_tokens"] == ( - corps["usage"]["prompt_tokens"] + corps["usage"]["completion_tokens"] - ) assert client.provider.appels_stream == [] diff --git a/tests/unit/test_openai_proxy_t343.py b/tests/unit/test_openai_proxy_t343.py new file mode 100644 index 0000000..110ab49 --- /dev/null +++ b/tests/unit/test_openai_proxy_t343.py @@ -0,0 +1,143 @@ +""" +tests/unit/test_openai_proxy_t343.py — #T343-C : le chemin streaming du proxy +/v1 porte l'étiquette d'agent `proxy_v1`. + +Mesuré le 16/08 sur la base de la machine où le proxy sert réellement : +13 lignes `token_usage` étiquetées `proxy_v1`, toutes issues du chemin +bufferisé, ZÉRO du chemin streaming. Or le streaming est le mode par défaut +de Cline / Continue : leur dépense partait anonyme. + +Pourquoi le `with agent_courant(...)` de la route ne suffit pas : la route +rend `StreamingResponse` immédiatement et c'est Starlette qui consomme le +générateur APRÈS, hors du bloc `with`. Le contexte doit être posé là où les +tokens sont réellement consommés, dans `_streamer_texte` — et le test doit +vérifier le contexte VU PAR LE PROVIDER (dans son thread, via le pont +`iterate_in_threadpool`), pas celui de la route : l'enregistrement d'usage se +fait pendant le `next()` du générateur, dans le thread du pool. + +Vérifié à la mesure (anyio 4, `_backends/_asyncio.py` l. 2510) : le pont +`iterate_in_threadpool` → `anyio.to_thread.run_sync` fait `copy_context()` +et le passe au worker — l'étiquette posée dans `_streamer_texte` atteint donc +bien le thread du provider. Aucun réseau : le provider résolu est un faux. +""" +import json +import threading + +import pytest + +from api.routes.openai_proxy import ( + ChatCompletionRequest, + ChatMessage, + _streamer_texte, + chat_completions, +) + + +class _FauxProvider: + """Provider factice : lit la ContextVar DEPUIS SON THREAD d'exécution, + comme le vrai code (record_usage tourne pendant le next() du pool).""" + + def __init__(self, nb_tokens=5): + self.nb_tokens = nb_tokens + self.agent_vu_en_stream = "" + self.agent_vu_bufferise = "" + self.stream_dans_thread_pool = None + + def generate(self, system_prompt, user_prompt, **kwargs): + from core.agent_trace import lire_agent_courant + self.agent_vu_bufferise = lire_agent_courant() + return "réponse factice" + + def generate_stream(self, system_prompt, user_prompt, **kwargs): + from core.agent_trace import lire_agent_courant + for i in range(self.nb_tokens): + # Exécuté dans le thread du pool (chaque next() via + # iterate_in_threadpool) : c'est ICI que record_usage écrirait. + self.agent_vu_en_stream = lire_agent_courant() + self.stream_dans_thread_pool = ( + threading.current_thread() is not threading.main_thread() + ) + yield {"token": f"tok{i}", "done": i == self.nb_tokens - 1, "usage": None} + + +@pytest.fixture +def faux_provider(monkeypatch): + provider = _FauxProvider() + monkeypatch.setattr( + "api.routes.openai_proxy._resoudre_provider", + lambda gateway, name: provider, + ) + return provider + + +def _requete(**champs): + return ChatCompletionRequest( + model="deepseek-chat", + messages=[ChatMessage(role="user", content="salut")], + **champs, + ) + + +# ── Test central : l'étiquette atteint le thread du provider en streaming ──── + +@pytest.mark.asyncio +async def test_streaming_etiquette_proxy_v1_vue_par_le_provider(faux_provider): + """`stream=true` sans outils : au moment où le double produit ses tokens, + `lire_agent_courant()` vaut `proxy_v1` — VU DEPUIS LE THREAD du provider.""" + reponse = await chat_completions(_requete(stream=True)) + + async for _ in reponse.body_iterator: + pass # consomme le flux : c'est là que le générateur tourne + + assert faux_provider.stream_dans_thread_pool, ( + "le provider doit s'exécuter dans le thread du pool — sinon ce test " + "ne prouve rien sur la propagation du contexte (cf. #T301)" + ) + assert faux_provider.agent_vu_en_stream == "proxy_v1", ( + f"étiquette perdue sur le chemin streaming : " + f"{faux_provider.agent_vu_en_stream!r} — c'est le symptôme d'un " + "contexte posé sur la route (Starlette consomme le générateur après " + "le bloc with) au lieu de `_streamer_texte`" + ) + + +# ── Non-régression : le chemin bufferisé garde son étiquette ───────────────── + +@pytest.mark.asyncio +async def test_chemin_bufferise_garde_l_etiquette_proxy_v1(faux_provider): + """`stream=false` (ou outils en streaming) : l'étiquette reste posée — + le correctif ne touche pas au chemin bufferisé de #T306.""" + await chat_completions(_requete()) + + assert faux_provider.agent_vu_bufferise == "proxy_v1" + + +# ── Non-régression : le streaming réel (5 tokens → ≥ 5 chunks) ─────────────── + +@pytest.mark.asyncio +async def test_streaming_5_tokens_produit_au_moins_5_chunks(faux_provider): + """Le streaming token-par-token de #T251 n'est pas cassé : 5 tokens + rendus par le provider → au moins 5 chunks de contenu dans le flux.""" + chunks = [] + async for chunk in _streamer_texte( + faux_provider, "deepseek-chat", "sys", "transcript", [], + temperature=0.7, max_tokens=256, + ): + chunks.append(chunk) + + contenus = [] + for c in chunks: + if not c.startswith("data: "): + continue + donnees = c[len("data: "):].strip() + if donnees == "[DONE]": + continue + delta = json.loads(donnees)["choices"][0]["delta"] + if delta.get("content"): + contenus.append(delta["content"]) + assert len(contenus) >= 5, ( + f"{len(contenus)} chunk(s) de contenu pour 5 tokens — le streaming " + "réel de #T251 serait cassé" + ) + assert contenus == [f"tok{i}" for i in range(5)], "ordre des tokens préservé" + assert chunks[-1].startswith("data: [DONE]") diff --git a/tests/unit/test_openai_proxy_usage_t357.py b/tests/unit/test_openai_proxy_usage_t357.py new file mode 100644 index 0000000..5954287 --- /dev/null +++ b/tests/unit/test_openai_proxy_usage_t357.py @@ -0,0 +1,290 @@ +""" +tests/unit/test_openai_proxy_usage_t357.py — Le `usage` du proxy /v1 (#T357). + +Le proxy annonçait un `usage` FAUX : sur le chemin non-streamé il publiait un +comptage de MOTS (`response_text.split()`) dans des champs nommés +`prompt_tokens` / `completion_tokens`, et sur `stream=true` + `tools` — le cas +normal d'un client d'IDE agentique — il n'émettait aucun usage du tout. Le +client cale sa fenêtre de contexte et son budget là-dessus ; 630 appels mesurés +le 17/08 avaient un prompt médian de 67 523 tokens réels. + +Le contrat vérifié ici tient en une phrase : **usage réel du provider, ou pas de +champ `usage` du tout**. Aucune estimation de repli, quelle qu'en soit la +formule — un chiffre faux qui rassure est pire qu'un chiffre absent. + +Aucun appel réseau : les providers sont des doubles, le LLMGateway est +monkeypatché. +""" + +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from api.routes import openai_proxy +from api.routes.openai_proxy import _extraire_usage_reel, _normaliser_usage + +USAGE_OPENAI = {"prompt_tokens": 67523, "completion_tokens": 412, "total_tokens": 67935} + + +# ────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────── + +def _chunks_sse(corps: str) -> list[dict]: + """Tous les chunks JSON d'une réponse SSE (hors [DONE]).""" + chunks = [] + for ligne in corps.splitlines(): + if not ligne.startswith("data: "): + continue + donnees = ligne[len("data: "):] + if donnees != "[DONE]": + chunks.append(json.loads(donnees)) + return chunks + + +class _ProviderFactice: + """Provider de test : `generate()` et `generate_stream()` scriptables.""" + + def __init__(self): + self.reponse_generate = "réponse du modèle" + self.chunks = [{"token": "salut", "done": True, "usage": None}] + + def generate(self, system_prompt, user_prompt, **kwargs): + return self.reponse_generate + + def generate_stream(self, system_prompt, user_prompt, **kwargs): + yield from self.chunks + + +@pytest.fixture +def client(monkeypatch): + """App FastAPI minimale portant uniquement le routeur du proxy.""" + provider = _ProviderFactice() + + class _Gateway: + def __init__(self): + self.providers = {"modele-a": object()} + + def get_provider(self, name): + if name.lower() not in self.providers: + raise ValueError(f"Provider LLM inconnu : {name}") + return provider + + def get_provider_for_tier(self, tier, config): + return f"tier-{tier}", provider + + monkeypatch.setattr("core.llm_gateway.LLMGateway", _Gateway, raising=False) + monkeypatch.setattr("core.llm_gateway.load_config", lambda: {}, raising=False) + + app = FastAPI() + app.include_router(openai_proxy.router) + test_client = TestClient(app) + test_client.provider = provider + return test_client + + +def _requete(stream: bool, outils: bool = False) -> dict: + corps = { + "model": "modele-a", + "messages": [{"role": "user", "content": "salut"}], + "stream": stream, + } + if outils: + corps["tools"] = [{"type": "function", "function": {"name": "read_file", "parameters": {}}}] + return corps + + +def _message_outil(usage: dict | None = None) -> dict: + """Message OpenAI d'appel d'outil, tel que `generate()` le retourne.""" + message = { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "read_file", "arguments": '{"path": "a.py"}'}, + }], + } + if usage is not None: + message["usage"] = usage + return message + + +# ────────────────────────────────────────────────────────────────── +# Chemin non-streamé +# ────────────────────────────────────────────────────────────────── + +def test_non_streame_transmet_l_usage_amont_a_l_identique(client): + """Usage réel remonté par le provider → transmis TEL QUEL, sans retouche.""" + client.provider.reponse_generate = { + "role": "assistant", "content": "bonjour", "usage": USAGE_OPENAI, + } + + reponse = client.post("/v1/chat/completions", json=_requete(stream=False)) + + assert reponse.status_code == 200 + assert reponse.json()["usage"] == USAGE_OPENAI + + +def test_non_streame_sans_usage_amont_n_a_aucune_cle_usage(client): + """LE garde-fou : pas d'usage amont → la clé `usage` est ABSENTE de la + réponse (elle est facultative côté OpenAI), et surtout pas remplie par une + estimation quelconque.""" + client.provider.reponse_generate = "réponse du modèle" + + reponse = client.post("/v1/chat/completions", json=_requete(stream=False)) + + corps = reponse.json() + assert "usage" not in corps, f"champ usage inventé : {corps.get('usage')!r}" + # Le reste de l'enveloppe OpenAI est intact. + assert set(corps) == {"id", "object", "created", "model", "choices"} + assert corps["choices"][0]["message"]["content"] == "réponse du modèle" + + +def test_aucune_estimation_ne_remplace_l_usage_absent(client): + """Anti-contournement : remplacer le comptage en mots par une AUTRE + estimation (heuristique 4 caractères/token, tiktoken…) est exactement le + défaut à corriger. Une réponse volumineuse sans usage amont ne doit + toujours produire aucun compteur.""" + client.provider.reponse_generate = "mot " * 5000 + + corps = client.post("/v1/chat/completions", json=_requete(stream=False)).json() + + assert "usage" not in corps + # Aucun compteur de tokens ne doit s'être glissé ailleurs dans l'enveloppe. + assert "token" not in json.dumps(corps).lower() + + +def test_non_streame_avec_outils_transmet_l_usage_amont(client): + """Un appel d'outil ne fait pas perdre l'usage réel.""" + client.provider.reponse_generate = _message_outil(usage=USAGE_OPENAI) + + corps = client.post("/v1/chat/completions", json=_requete(stream=False, outils=True)).json() + + assert corps["choices"][0]["finish_reason"] == "tool_calls" + assert corps["usage"] == USAGE_OPENAI + + +# ────────────────────────────────────────────────────────────────── +# Chemin streamé + outils (le cas normal d'un client agentique) +# ────────────────────────────────────────────────────────────────── + +def test_streame_avec_outils_porte_l_usage_dans_le_chunk_final(client): + """`stream=true` + `tools` passe par le chemin bufferisé : si la réponse du + provider porte un usage réel, il doit arriver au client sur le chunk final + (celui qui porte `finish_reason`).""" + client.provider.reponse_generate = _message_outil(usage=USAGE_OPENAI) + + reponse = client.post("/v1/chat/completions", json=_requete(stream=True, outils=True)) + + assert reponse.status_code == 200 + chunks = _chunks_sse(reponse.text) + final = chunks[-1] + assert final["choices"][0]["finish_reason"] == "tool_calls" + assert final["usage"] == USAGE_OPENAI + # Le chunk de contenu, lui, ne porte pas d'usage (contrat OpenAI). + assert "usage" not in chunks[0] + assert chunks[0]["choices"][0]["delta"]["tool_calls"][0]["function"]["name"] == "read_file" + + +def test_streame_avec_outils_sans_usage_amont_n_invente_rien(client): + """Même chemin, sans usage amont : aucun chunk ne porte de champ `usage`.""" + client.provider.reponse_generate = _message_outil() + + reponse = client.post("/v1/chat/completions", json=_requete(stream=True, outils=True)) + + chunks = _chunks_sse(reponse.text) + assert chunks, "le flux doit contenir au moins un chunk" + assert all("usage" not in c for c in chunks) + + +def test_streame_texte_sans_usage_amont_n_invente_rien(client): + """Streaming token-par-token sans usage remonté : aucun chunk ne porte de + champ `usage` (le provider a le droit de ne pas supporter + `stream_options.include_usage`).""" + client.provider.chunks = [ + {"token": "Bon", "done": False, "usage": None}, + {"token": "jour", "done": False, "usage": None}, + {"token": "", "done": True, "usage": None}, + ] + + reponse = client.post("/v1/chat/completions", json=_requete(stream=True)) + + chunks = _chunks_sse(reponse.text) + assert len(chunks) >= 3 + assert all("usage" not in c for c in chunks) + + +def test_streame_texte_traduit_l_usage_gemini_natif(client): + """gemini_native.generate_stream() remonte un `usageMetadata` (schéma + Gemini) sur son chunk final. Transmis tel quel, le client OpenAI ne + trouverait AUCUNE des clés qu'il lit : les compteurs sont renommés.""" + client.provider.chunks = [ + {"token": "oui", "done": False, "usage": None}, + {"token": "", "done": True, "usage": { + "promptTokenCount": 67523, + "candidatesTokenCount": 412, + "totalTokenCount": 67935, + }}, + ] + + reponse = client.post("/v1/chat/completions", json=_requete(stream=True)) + + final = _chunks_sse(reponse.text)[-1] + assert final["usage"] == USAGE_OPENAI + + +# ────────────────────────────────────────────────────────────────── +# Normalisation : renommage strict, jamais de calcul +# ────────────────────────────────────────────────────────────────── + +def test_normaliser_usage_laisse_le_schema_openai_intact(): + """Passthrough total, extras compris (cache DeepSeek, détails OpenAI) : + on ne réécrit pas ce que le provider a compté.""" + usage = { + "prompt_tokens": 67523, + "completion_tokens": 412, + "total_tokens": 67935, + "prompt_cache_hit_tokens": 65000, + "prompt_tokens_details": {"cached_tokens": 65000}, + } + assert _normaliser_usage(usage) == usage + + +def test_normaliser_usage_traduit_le_schema_anthropic(): + assert _normaliser_usage({"input_tokens": 120, "output_tokens": 8}) == { + "prompt_tokens": 120, "completion_tokens": 8, + } + + +def test_normaliser_usage_ne_calcule_jamais_le_total(): + """`total_tokens` n'est posé que si le provider l'a fourni : on ne dérive + aucune valeur, même par une somme.""" + traduit = _normaliser_usage({"promptTokenCount": 100, "candidatesTokenCount": 20}) + assert traduit == {"prompt_tokens": 100, "completion_tokens": 20} + assert "total_tokens" not in traduit + + +@pytest.mark.parametrize("brut", [ + None, + {}, + "12 tokens", + {"total_tokens": 42}, # un total seul ne dit rien de l'entrée + {"prompt_tokens": None}, # clé présente mais vide + {"prompt_tokens": True}, # bool : `isinstance(True, int)` est vrai + {"tokens_utilises": 900}, # schéma inconnu +]) +def test_normaliser_usage_refuse_ce_qui_n_est_pas_un_comptage(brut): + """Tout ce qui n'est pas un comptage réel exploitable donne None — donc une + omission du champ en aval, jamais une valeur de remplacement.""" + assert _normaliser_usage(brut) is None + + +def test_extraire_usage_reel_ignore_une_reponse_texte(): + """`generate()` renvoie une chaîne dans le cas courant : aucun usage à en + tirer, et rien à inventer.""" + assert _extraire_usage_reel("réponse du modèle") is None + assert _extraire_usage_reel({"content": "bonjour"}) is None + assert _extraire_usage_reel({"content": "bonjour", "usage": USAGE_OPENAI}) == USAGE_OPENAI diff --git a/tests/unit/test_ordre_cascade_vocale.py b/tests/unit/test_ordre_cascade_vocale.py new file mode 100644 index 0000000..d8bffd1 --- /dev/null +++ b/tests/unit/test_ordre_cascade_vocale.py @@ -0,0 +1,46 @@ +import pytest +from unittest.mock import AsyncMock, patch +from core.vocal_host import handle_discussion + +@pytest.mark.asyncio +async def test_cascade_order(): + """ + Vérifie que les court-circuits Zero-LLM sont consultés AVANT les spécialistes. + On simule les résolveurs pour forcer une réponse à chaque étage. + """ + + # Phrases de test + phrase_ha_cmd = "allume la lumière du salon" + phrase_ha_state = "la lumière du salon est allumée ?" + phrase_weather = "quel temps fait-il ?" + phrase_datetime = "quelle heure est-il ?" + phrase_calendar = "quand est-ce que je travaille ?" + phrase_chat = "raconte une histoire" + + # Mock des résolveurs + with patch("core.vocal_host._try_zero_llm_ha_command", new_callable=AsyncMock) as mock_ha_cmd, \ + patch("core.vocal_host._try_zero_llm_ha_state", new_callable=AsyncMock) as mock_ha_state, \ + patch("core.vocal_host._try_zero_llm_ha_weather", new_callable=AsyncMock) as mock_ha_weather, \ + patch("services.datetime_query.resolve_datetime_query", return_value="Il est 12h00") as mock_dt: + + # Test HA Commande + mock_ha_cmd.return_value = "Commande exécutée" + result = await handle_discussion(user_prompt=phrase_ha_cmd, session_id="test", gateway=None, token_tracker=None, fast_path_cache=None) + assert result.routing_type == "discussion_ha_command" + + # Test HA État (si commande échoue) + mock_ha_cmd.return_value = None + mock_ha_state.return_value = "La lumière est allumée" + result = await handle_discussion(user_prompt=phrase_ha_state, session_id="test", gateway=None, token_tracker=None, fast_path_cache=None) + assert result.routing_type == "discussion_ha_state" + + # Test HA Météo (si état échoue) + mock_ha_state.return_value = None + mock_ha_weather.return_value = "Il fait beau" + result = await handle_discussion(user_prompt=phrase_weather, session_id="test", gateway=None, token_tracker=None, fast_path_cache=None) + assert result.routing_type == "discussion_ha_weather" + + # Test Date/Heure (si météo échoue) + mock_ha_weather.return_value = None + result = await handle_discussion(user_prompt=phrase_datetime, session_id="test", gateway=None, token_tracker=None, fast_path_cache=None) + assert result.routing_type == "discussion_datetime" diff --git a/tests/unit/test_privacy_local_only_t337.py b/tests/unit/test_privacy_local_only_t337.py new file mode 100644 index 0000000..2d1e4d3 --- /dev/null +++ b/tests/unit/test_privacy_local_only_t337.py @@ -0,0 +1,198 @@ +""" +tests/unit/test_privacy_local_only_t337.py — Mode confidentialité « local_only » (FAIL-CLOSED). + +[T337] Quand `MOTEUR_PRIVACY_LEVEL=local_only` (ou le paramètre d'appel +`privacy_level="local_only"`) est actif, AUCUNE requête ne doit partir vers un +provider cloud : seuls les modèles servis par un provider LOCAL sont retenus, et +si aucun n'est disponible dans le tier demandé, l'appel ÉCHOUE explicitement — +jamais de repli silencieux vers le cloud. + +Le point central vérifié ici (test_privacy_local_only_aucun_local_echoue) est le +FAIL-CLOSED : mode actif + aucun modèle local → l'appel lève une erreur explicite, +et le test prouve qu'AUCUN provider cloud n'a été instancié ni appelé. +""" + +import pytest + +from core.llm.providers.base import LLMProvider +from core.llm.providers.deepseek import FallbackProvider +from core.llm_gateway import ( + MOTEUR_PRIVACY_LEVEL_ENV, + MOTEUR_PRIVACY_LOCAL_ONLY, + LLMGateway, +) + +# Endpoints types : un provider local (loopback / LAN privé) et un provider cloud (domaine public). +BASE_URL_LOCAL = "http://127.0.0.1:11434/v1/chat/completions" +BASE_URL_CLOUD = "https://api.deepseek.com/chat/completions" + + +class _FakeProvider(LLMProvider): + """Provider factice avec un endpoint et un compteur d'appels. + + Le `base_url` sert de marqueur de localité : `_is_local_provider` le lit pour + décider si le provider appartient à la famille locale (cas réel en prod, où + tous les providers locaux — Ollama, LM Studio, Deck Edge AI — ont un endpoint + local, et tous les clouds un endpoint public). + """ + + def __init__(self, base_url: str): + self.base_url = base_url + self.calls = 0 + + def generate(self, system_prompt, user_prompt, **kwargs): + self.calls += 1 + return "réponse factice suffisamment longue pour l'heuristique de qualité" + + def generate_structured(self, system_prompt, user_prompt, schema, **kwargs): + self.calls += 1 + return {"ok": True} + + +@pytest.fixture +def gateway(): + return LLMGateway() + + +def _config_tier(model_names: list) -> dict: + """Config minimale : le tier 'leger' ne contient QUE les modèles donnés.""" + return {"tiers": {"leger": model_names}, "routing_policy": {"excluded_models": []}} + + +@pytest.fixture(autouse=True) +def _isoler_db_tiers(monkeypatch): + """Force la résolution de tier à repasser par config.json (la DB est ignorée).""" + import core.models_db as models_db + + monkeypatch.setattr(models_db, "get_models_for_tier", lambda _tier: []) + yield + + +@pytest.fixture(autouse=True) +def _env_privacy_vide(monkeypatch): + """Isolation : variable d'environnement vide par défaut (comportement historique).""" + monkeypatch.delenv(MOTEUR_PRIVACY_LEVEL_ENV, raising=False) + yield + + +def test_privacy_local_only_aucun_local_echoue_fail_closed(gateway): + """POINT CENTRAL — FAIL-CLOSED. + + Mode actif + AUCUN modèle local dans le tier demandé → l'appel échoue + explicitement (ValueError) et le test prouve qu'AUCUN provider cloud n'a été + instancié ni appelé : le seul modèle candidat est cloud, il est écarté, et + l'appel lève AVANT de construire un FallbackProvider. + """ + cloud_provider = _FakeProvider(BASE_URL_CLOUD) + gateway.providers["modele-cloud-unique"] = cloud_provider + config = _config_tier(["modele-cloud-unique"]) + + with pytest.raises(ValueError, match="aucun modèle local"): + gateway.get_provider_for_tier("leger", config, privacy_level=MOTEUR_PRIVACY_LOCAL_ONLY) + + # Aucun provider cloud appelé. + assert cloud_provider.calls == 0 + # Le modèle cloud est bien identifié comme NON local (donc écarté) : la preuve + # que le cloud n'a pas été retenu dans une liste de repli. + assert gateway._is_local_provider("modele-cloud-unique") is False + + +def test_privacy_local_only_retenu_local_aucun_cloud_en_repli(gateway): + """Mode actif + un local disponible → le local est retenu et AUCUN cloud ne + reste dans la liste de repli du FallbackProvider (on vérifie le CONTENU de la + liste, pas seulement le premier élément — sinon le repli cloud resterait armé).""" + local_provider = _FakeProvider(BASE_URL_LOCAL) + cloud_provider = _FakeProvider(BASE_URL_CLOUD) + gateway.providers["mon-modele-local"] = local_provider + gateway.providers["modele-cloud"] = cloud_provider + config = _config_tier(["mon-modele-local", "modele-cloud"]) + + _tier, provider = gateway.get_provider_for_tier( + "leger", config, privacy_level=MOTEUR_PRIVACY_LOCAL_ONLY + ) + + assert isinstance(provider, FallbackProvider) + # Le contenu complet de la liste ne contient QUE le local — aucun cloud armé + # derrière en repli. + modeles_retenus = [m for m, _ in provider.providers] + assert modeles_retenus == ["mon-modele-local"] + assert "modele-cloud" not in modeles_retenus + # Le provider local est bien celui qui a été retenu. + assert provider.providers[0][1].provider is local_provider + # Jamais d'appel réseau pendant la résolution. + assert local_provider.calls == 0 + assert cloud_provider.calls == 0 + + +def test_privacy_inactif_liste_identique_avant_changement(gateway): + """Mode inactif (défaut) → la liste retenue est IDENTIQUE à celle d'avant le + changement : le filtrage local ne s'applique PAS, tous les modèles candidats + (locaux ET cloud) du tier sont conservés.""" + local_provider = _FakeProvider(BASE_URL_LOCAL) + cloud_provider = _FakeProvider(BASE_URL_CLOUD) + gateway.providers["mon-modele-local"] = local_provider + gateway.providers["modele-cloud"] = cloud_provider + config = _config_tier(["mon-modele-local", "modele-cloud"]) + + _tier, provider = gateway.get_provider_for_tier("leger", config) + + modeles_retenus = set(m for m, _ in provider.providers) + # Les deux modèles (local + cloud) sont conservés : comportement historique. + assert modeles_retenus == {"mon-modele-local", "modele-cloud"} + + +def test_privacy_inactif_par_env_vide_comme_parametre_absent(gateway): + """Vide via la variable d'environnement = comportement historique, identique à + un appel sans le paramètre privacy_level.""" + local_provider = _FakeProvider(BASE_URL_LOCAL) + cloud_provider = _FakeProvider(BASE_URL_CLOUD) + gateway.providers["mon-modele-local"] = local_provider + gateway.providers["modele-cloud"] = cloud_provider + config = _config_tier(["mon-modele-local", "modele-cloud"]) + + _tier, provider = gateway.get_provider_for_tier("leger", config) + + assert set(m for m, _ in provider.providers) == {"mon-modele-local", "modele-cloud"} + + +def test_privacy_parametre_appel_prime_sur_env(gateway, monkeypatch): + """Le paramètre d'appel prime sur la variable d'environnement : env à local_only + mais appel sans le paramètre → le mode s'applique (l'environnement est lu).""" + monkeypatch.setenv(MOTEUR_PRIVACY_LEVEL_ENV, MOTEUR_PRIVACY_LOCAL_ONLY) + cloud_provider = _FakeProvider(BASE_URL_CLOUD) + gateway.providers["modele-cloud-unique"] = cloud_provider + config = _config_tier(["modele-cloud-unique"]) + + # Sans paramètre explicite, l'env local_only est bien pris en compte → fail-closed. + with pytest.raises(ValueError, match="aucun modèle local"): + gateway.get_provider_for_tier("leger", config) + assert cloud_provider.calls == 0 + + +def test_privacy_parametre_explicite_prime_sur_env_actif(gateway, monkeypatch): + """Le paramètre d'appel prime sur l'env : env vide mais appel avec + privacy_level="local_only" → le mode s'applique (fail-closed).""" + monkeypatch.delenv(MOTEUR_PRIVACY_LEVEL_ENV, raising=False) + cloud_provider = _FakeProvider(BASE_URL_CLOUD) + gateway.providers["modele-cloud-unique"] = cloud_provider + config = _config_tier(["modele-cloud-unique"]) + + with pytest.raises(ValueError, match="aucun modèle local"): + gateway.get_provider_for_tier( + "leger", config, privacy_level=MOTEUR_PRIVACY_LOCAL_ONLY + ) + assert cloud_provider.calls == 0 + + +def test_privacy_local_only_avec_parametre_non_local_only_inchange(gateway): + """Une valeur de privacy_level autre que 'local_only' (ex: vide) préserve le + comportement historique : le cloud reste retenu.""" + local_provider = _FakeProvider(BASE_URL_LOCAL) + cloud_provider = _FakeProvider(BASE_URL_CLOUD) + gateway.providers["mon-modele-local"] = local_provider + gateway.providers["modele-cloud"] = cloud_provider + config = _config_tier(["mon-modele-local", "modele-cloud"]) + + _tier, provider = gateway.get_provider_for_tier("leger", config, privacy_level="") + + assert set(m for m, _ in provider.providers) == {"mon-modele-local", "modele-cloud"} diff --git a/tests/unit/test_provider_health.py b/tests/unit/test_provider_health.py new file mode 100644 index 0000000..2b79f3a --- /dev/null +++ b/tests/unit/test_provider_health.py @@ -0,0 +1,312 @@ +"""test_provider_health.py — #T352, sonde d'accessibilité d'hôte du fast-path. + +Journal de PROD du 17/08 : `ollama_pc` (192.168.1.10, simplement éteint) coûtait +2,56 s de connect timeout + backoff avant bascule, à CHAQUE phrase, parce que le +disjoncteur n'ouvre qu'après trois échecs. La sonde doit écarter l'hôte muet +avant le premier essai. + +Aucun appel réseau réel ici : `_sonder` (la vraie ouverture TCP) est mockée, et +la sonde de cascade (`_fast_path_hote_muet`) est contrôlée par mock. +""" +import asyncio +import time +from unittest.mock import AsyncMock, Mock, patch + +import pytest + +from core.llm import provider_health +from core.llm.provider_health import ( + TTL_SUCCES_S, + base_url_provider, + hote_et_port, + hote_joignable, +) +from core.llm.providers.deepseek import FallbackProvider +from core.llm_gateway import LLMGateway +from services import pipeline_service + +# ────────────────────────────────────────────────────────────────── +# hote_et_port +# ────────────────────────────────────────────────────────────────── + +class TestHoteEtPort: + def test_ip_et_port_explicites(self): + assert hote_et_port("http://192.168.1.10:11434/v1/chat/completions") == ( + "192.168.1.10", 11434, + ) + + def test_sans_port_retourne_none(self): + assert hote_et_port("http://192.168.1.10/v1") is None + + def test_url_illisible_retourne_none(self): + assert hote_et_port("::pas-une-url::") is None + + +# ────────────────────────────────────────────────────────────────── +# base_url_provider — déballage des wrappers / cascades +# ────────────────────────────────────────────────────────────────── + +class TestBaseUrlProvider: + def test_provider_brut(self): + class _P: + base_url = "http://192.168.1.10:11434/v1" + assert base_url_provider(_P()) == "http://192.168.1.10:11434/v1" + + def test_fallback_provider_deballe_son_premier_candidat(self): + class _P: + base_url = "http://192.168.1.10:11434/v1" + cascade = FallbackProvider([("ollama_pc", _P())]) + assert base_url_provider(cascade) == "http://192.168.1.10:11434/v1" + + def test_wrapper_decorateur_deballe(self): + class _P: + base_url = "http://127.0.0.1:1337/v1" + class _Wrapper: + def __init__(self, p): + self.provider = p + assert base_url_provider(_Wrapper(_P())) == "http://127.0.0.1:1337/v1" + + def test_aucun_base_url_retourne_none(self): + class _SansUrl: + pass + assert base_url_provider(_SansUrl()) is None + + +# ────────────────────────────────────────────────────────────────── +# hote_joignable — cache + verrou + TTL +# ────────────────────────────────────────────────────────────────── + +@pytest.fixture(autouse=True) +def _vider_cache_health(): + provider_health._cache.clear() + provider_health._verrous.clear() + yield + provider_health._cache.clear() + provider_health._verrous.clear() + + +class TestHoteJoignable: + @patch("core.llm.provider_health._sonder", new_callable=AsyncMock) + async def test_deux_appels_rapproches_ne_lancent_qu_une_sonde(self, mock_sonder): + """Le cache par (hôte, port) évite de resonder un hôte déjà testé.""" + mock_sonder.return_value = True + assert await hote_joignable("http://192.168.1.10:11434/v1") + assert await hote_joignable("http://192.168.1.10:11434/v1") + assert mock_sonder.await_count == 1 + + @patch("core.llm.provider_health._sonder", new_callable=AsyncMock) + async def test_une_seule_sonde_en_vol_par_hote(self, mock_sonder): + """Dix requêtes simultanées ne lancent pas dix sondes (verrou par clé).""" + mock_sonder.return_value = True + url = "http://192.168.1.10:11434/v1" + resultats = await asyncio.gather(*[hote_joignable(url) for _ in range(10)]) + assert all(resultats) + assert mock_sonder.await_count == 1 + + @patch("core.llm.provider_health._sonder", new_callable=AsyncMock) + async def test_ttl_echac_court_permet_de_ressonder(self, mock_sonder): + """Un échec est mémorisé court (60 s) : le PC rétabli est repris vite.""" + mock_sonder.return_value = False + url = "http://192.168.1.10:11434/v1" + assert await hote_joignable(url) is False + # TTL échec court : on expire le cache pour simuler son retour. + cle = ("192.168.1.10", 11434) + provider_health._cache[cle] = (0.0, False) # expiration passée + mock_sonder.return_value = True + assert await hote_joignable(url) is True + assert mock_sonder.await_count == 2 + + @patch("core.llm.provider_health._sonder", new_callable=AsyncMock) + async def test_succes_ttl_long(self, mock_sonder): + """Un succès est mémorisé 300 s : pas de resonde pendant longtemps.""" + mock_sonder.return_value = True + url = "http://192.168.1.10:11434/v1" + await hote_joignable(url) + cle = ("192.168.1.10", 11434) + expiration, resultat = provider_health._cache[cle] + assert resultat is True + assert expiration - time.monotonic() > TTL_SUCCES_S * 0.9 + + @patch("core.llm.provider_health._sonder", new_callable=AsyncMock) + async def test_exception_de_sonde_devient_false(self, mock_sonder): + """Toute exception (socket, DNS, timeout) = « on ne sait pas » = False.""" + mock_sonder.side_effect = OSError("connexion refusée") + assert await hote_joignable("http://192.168.1.10:11434/v1") is False + + async def test_hote_illisible_est_considere_joignable(self): + """Doute (URL illisible) → on ne refuse jamais : True, sans sonde.""" + with patch("core.llm.provider_health._sonder", new_callable=AsyncMock) as mock_sonder: + assert await hote_joignable("http://pas-de-port/v1") is True + mock_sonder.assert_not_awaited() + + +# ────────────────────────────────────────────────────────────────── +# _fast_path_hote_muet — le filtre lui-même +# ────────────────────────────────────────────────────────────────── + +class TestFastPathHoteMuet: + def _gateway(self): + g = Mock() + g._est_hote_local = Mock(side_effect=lambda h: LLMGateway._est_hote_local(h)) + return g + + def _provider(self, base_url): + class _P: + def __init__(self, url): + self.base_url = url + return _P(base_url) + + @patch("core.llm.provider_health.hote_joignable", new_callable=AsyncMock) + async def test_hote_local_muet_retourne_true(self, mock_joignable): + mock_joignable.return_value = False # ne décroche pas + g = self._gateway() + p = self._provider("http://192.168.1.10:11434/v1") + assert await pipeline_service._fast_path_hote_muet(g, "ollama_pc", p) is True + mock_joignable.assert_awaited_once() + + @patch("core.llm.provider_health.hote_joignable", new_callable=AsyncMock) + async def test_hote_local_qui_decroche_retourne_false(self, mock_joignable): + mock_joignable.return_value = True + g = self._gateway() + p = self._provider("http://192.168.1.10:11434/v1") + assert await pipeline_service._fast_path_hote_muet(g, "ollama_pc", p) is False + + @patch("core.llm.provider_health.hote_joignable", new_callable=AsyncMock) + async def test_provider_cloud_jamais_sondé(self, mock_joignable): + """Un hôte cloud ne doit JAMAIS être écarté par une sonde TCP.""" + g = self._gateway() + p = self._provider("https://api.deepseek.com/v1/chat/completions") + assert await pipeline_service._fast_path_hote_muet(g, "deepseek-chat", p) is False + mock_joignable.assert_not_awaited() + + @patch("core.llm.provider_health.hote_joignable", new_callable=AsyncMock) + async def test_exception_de_sonde_ne_refuse_pas(self, mock_joignable): + """Le doute ne doit pas devenir un refus : exception → on tente.""" + mock_joignable.side_effect = OSError("DNS en panne") + g = self._gateway() + p = self._provider("http://192.168.1.10:11434/v1") + assert await pipeline_service._fast_path_hote_muet(g, "ollama_pc", p) is False + + +# ────────────────────────────────────────────────────────────────── +# Cascade fast-path — run_fast_path avec gateway mocké +# ────────────────────────────────────────────────────────────────── + +class _ProviderCascade: + """Provider factice : base_url + generate compteur d'appels.""" + + def __init__(self, base_url, reponse): + self.base_url = base_url + self.reponse = reponse + self.appels = 0 + + def generate(self, system_prompt, user_prompt, **kwargs): + self.appels += 1 + return self.reponse + + +class _GatewayCascade: + """Gateway factice : get_provider + vrai critère _est_hote_local.""" + + def __init__(self, providers): + self._providers = providers + + def get_provider(self, name): + if name not in self._providers: + raise ValueError(f"Provider LLM inconnu : {name}") + return self._providers[name] + + @staticmethod + def _est_hote_local(host): + return LLMGateway._est_hote_local(host) + + +class TestCascadeFastPath: + def _gateway_avec(self, providers): + return _GatewayCascade(providers) + + async def _run(self, gateway, noms_providers): + token_tracker = Mock() + token_tracker.init_session = Mock() + with ( + patch("core.agent_trace.poser_agent_courant"), + patch.object(pipeline_service, "_persist_fast_path_async", new_callable=AsyncMock), + patch.object(pipeline_service, "FAST_PATH_PROVIDERS", noms_providers), + ): + return await pipeline_service.run_fast_path( + user_prompt="bonjour", + session_id="test-session", + gateway=gateway, + token_tracker=token_tracker, + fast_path_cache={}, + ) + + @patch.object(pipeline_service, "_fast_path_hote_muet", new_callable=AsyncMock) + async def test_hote_muet_saute_et_le_suivant_repond(self, mock_sonde): + """Hôte muet → provider sauté, le suivant est appelé.""" + local_muet = _ProviderCascade("http://192.168.1.10:11434/v1", "muet") + local_ok = _ProviderCascade("http://192.168.1.10:11434/v1", "ok") + mock_sonde.side_effect = [True] # le premier est muet → sauté + gateway = self._gateway_avec({"ollama_pc": local_muet, "autre_local": local_ok}) + + reponse = await self._run(gateway, ["ollama_pc", "autre_local"]) + + assert reponse == "ok" + assert local_muet.appels == 0, "le provider muet ne doit pas être tenté" + assert local_ok.appels == 1 + + @patch.object(pipeline_service, "_fast_path_hote_muet", new_callable=AsyncMock) + async def test_hote_qui_decroche_est_tente(self, mock_sonde): + """Hôte qui décroche → provider tenté normalement.""" + local_ok = _ProviderCascade("http://192.168.1.10:11434/v1", "ok") + mock_sonde.side_effect = [False] # décroche + gateway = self._gateway_avec({"ollama_pc": local_ok}) + + reponse = await self._run(gateway, ["ollama_pc"]) + + assert reponse == "ok" + assert local_ok.appels == 1 + + @patch.object(pipeline_service, "_fast_path_hote_muet", new_callable=AsyncMock) + async def test_tous_injoignables_le_dernier_est_tente(self, mock_sonde): + """Ne jamais vider la cascade : le dernier est tenté même si muet.""" + local_a = _ProviderCascade("http://192.168.1.10:11434/v1", "a") + local_b = _ProviderCascade("http://192.168.1.10:11434/v1", "b") + # Le premier est muet (sauté) ; le dernier n'est jamais sondé. + mock_sonde.side_effect = [True] + gateway = self._gateway_avec({"a": local_a, "b": local_b}) + + reponse = await self._run(gateway, ["a", "b"]) + + assert reponse == "b" + assert local_a.appels == 0 + assert local_b.appels == 1 + assert mock_sonde.await_count == 1, "le dernier candidat ne doit pas être sondé" + + @patch.object(pipeline_service, "_fast_path_hote_muet", new_callable=AsyncMock) + async def test_exception_de_sonde_ne_bloque_pas_la_cascade(self, mock_sonde): + """Une exception dans la sonde ne doit pas tuer la cascade.""" + local_ok = _ProviderCascade("http://192.168.1.10:11434/v1", "ok") + # La sonde lève (comportement de _fast_path_hote_muet après exception : + # elle retourne False → on tente). Ici on simule le résultat final False. + mock_sonde.side_effect = [False] + gateway = self._gateway_avec({"ollama_pc": local_ok}) + + reponse = await self._run(gateway, ["ollama_pc"]) + + assert reponse == "ok" + assert local_ok.appels == 1 + + @patch.object(pipeline_service, "_fast_path_hote_muet", new_callable=AsyncMock) + async def test_provider_cloud_de_la_cascade_n_est_pas_sonde(self, mock_sonde): + """Le filtre ne sonde jamais un cloud : cascade intacte en tête.""" + cloud = _ProviderCascade("https://api.cerebras.ai/v1/chat/completions", "cloud") + # Le premier (cloud) doit être tenté (jamais sondé → filtre False) ; le + # second est le dernier (jamais sondé non plus). + mock_sonde.side_effect = [False] + gateway = self._gateway_avec({"gpt-oss-120b": cloud, "ollama_pc": cloud}) + + reponse = await self._run(gateway, ["gpt-oss-120b", "ollama_pc"]) + + assert reponse == "cloud" + assert mock_sonde.await_count == 1 diff --git a/tests/unit/test_proxy_usage_reel_provider.py b/tests/unit/test_proxy_usage_reel_provider.py new file mode 100644 index 0000000..876cf87 --- /dev/null +++ b/tests/unit/test_proxy_usage_reel_provider.py @@ -0,0 +1,441 @@ +""" +tests/unit/test_proxy_usage_reel_provider.py — L'usage RÉEL du provider arrive +au client du proxy /v1 (complète #T357). + +#T357 a posé le contrat côté proxy : le champ `usage` ne porte que des +comptages réels, sinon il est omis. Restait le défaut en amont : +`OpenAICompatibleProvider.generate_stream()` capturait l'usage réel +(`stream_options.include_usage`) pour l'écrire en base… puis yieldait +`usage: None` ; et `generate()` l'enregistrait en base avant de rendre une +simple chaîne — l'information était jetée juste en dessous de là où le proxy +aurait pu la lire. + +Ce fichier vérifie les deux chemins de bout en bout, avec un VRAI +`OpenAICompatibleProvider` dont la couche HTTP est simulée (aucun appel +réseau), servi par la route `/v1/chat/completions` réelle : + +- streaming : le chunk final porte l'usage réel TEL QUEL (aucune valeur + recalculée), et rien quand le provider ignore `include_usage` — l'estimation + de repli calculée pour la base ne remonte JAMAIS au client ; +- bufferisé : l'usage réel transite par le canal latéral `_usage_sink` (un + dict propre à la requête, jamais d'état partagé sur l'instance), sans aucun + changement pour les appelants qui attendent une chaîne ; +- `record_usage` est appelé EXACTEMENT UNE FOIS par requête : faire remonter + l'usage ne doit pas provoquer une seconde écriture en base. + +Aucun comptage n'est estimé. +""" + +import json +import os +import sys +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +# Ajout du répertoire parent au PYTHONPATH (motif des tests existants) +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +from api.routes import openai_proxy +from core.llm.providers.deepseek import FallbackProvider +from core.openai_compat_provider import OpenAICompatibleProvider + +USAGE_REEL = { + "prompt_tokens": 67523, + "completion_tokens": 412, + "total_tokens": 67935, + "prompt_cache_hit_tokens": 65000, +} + + +# ────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────── + +def _chunks_sse(corps: str) -> list[dict]: + """Tous les chunks JSON d'une réponse SSE (hors [DONE]).""" + chunks = [] + for ligne in corps.splitlines(): + if not ligne.startswith("data: "): + continue + donnees = ligne[len("data: "):] + if donnees != "[DONE]": + chunks.append(json.loads(donnees)) + return chunks + + +def _lignes_sse(usage_final: dict | None, avec_done: bool = True) -> list[str]: + """Corps SSE OpenAI-compatible simulé : deux deltas de contenu, un chunk + final d'usage (si le provider honore `include_usage`), puis [DONE].""" + lignes = [ + 'data: {"id": "c1", "choices": [{"delta": {"content": "Bon"}}]}', + 'data: {"id": "c2", "choices": [{"delta": {"content": "jour"}, ' + '"finish_reason": "stop"}]}', + ] + if usage_final is not None: + # Forme réelle du chunk include_usage : choices vide, usage rempli. + lignes.append( + 'data: {"id": "c3", "choices": [], "usage": ' + + json.dumps(usage_final) + "}" + ) + if avec_done: + lignes.append("data: [DONE]") + return lignes + + +def _reponse_stream(lignes: list[str]) -> MagicMock: + reponse = MagicMock() + reponse.status_code = 200 + reponse.iter_lines.return_value = iter(lignes) + return reponse + + +def _reponse_json(corps: dict) -> MagicMock: + reponse = MagicMock() + reponse.status_code = 200 + reponse.json.return_value = corps + return reponse + + +def _provider() -> OpenAICompatibleProvider: + return OpenAICompatibleProvider( + provider_name="TestProvider", + base_url="http://fake.local/v1/chat/completions", + api_key="cle-test-fake", + model="modele-test", + ) + + +@pytest.fixture +def compteur_record_usage(monkeypatch): + """Compte les écritures en base au lieu de les faire (isolation totale). + + `record_usage` est importé paresseusement DANS les méthodes du provider + (`from core.token_tracker import record_usage`) : patcher l'attribut du + module intercepte donc chaque appel. + """ + appels = [] + + def _faux_record_usage(model, prompt_tokens, completion_tokens, **kwargs): + appels.append({ + "model": model, + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + **kwargs, + }) + + monkeypatch.setattr("core.token_tracker.record_usage", _faux_record_usage) + return appels + + +@pytest.fixture +def client_proxy(monkeypatch): + """App FastAPI portant le routeur réel du proxy, dont le gateway renvoie + un VRAI FallbackProvider autour du provider OpenAI-compatible simulé : la + chaîne de wrappers complète (ClaudeInstructionsWrapper → FallbackProvider) + est exercée, comme en production.""" + provider = _provider() + + class _Gateway: + def __init__(self): + self.providers = {"modele-test": object()} + + def get_provider(self, name): + if name.lower() not in self.providers: + raise ValueError(f"Provider LLM inconnu : {name}") + from core.llm.providers.deepseek import ClaudeInstructionsWrapper + return FallbackProvider( + [(name.lower(), ClaudeInstructionsWrapper(provider))] + ) + + def get_provider_for_tier(self, tier, config): + raise NotImplementedError + + monkeypatch.setattr("core.llm_gateway.LLMGateway", _Gateway, raising=False) + + app = FastAPI() + app.include_router(openai_proxy.router) + test_client = TestClient(app) + test_client.provider_reel = provider + return test_client + + +def _requete(stream: bool, outils: bool = False) -> dict: + corps = { + "model": "modele-test", + "messages": [{"role": "user", "content": "salut"}], + "stream": stream, + } + if outils: + corps["tools"] = [{ + "type": "function", + "function": {"name": "read_file", "parameters": {}}, + }] + return corps + + +# ────────────────────────────────────────────────────────────────── +# Contrats du provider seul (generate_stream / generate) +# ────────────────────────────────────────────────────────────────── + +class TestGenerateStreamContratUsage: + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_chunk_final_porte_l_usage_reel_tel_quel(self, mock_session, + compteur_record_usage): + """Le provider renvoie un chunk final d'usage → le chunk de clôture du + générateur porte EXACTEMENT ces valeurs, sans rien de recalculé.""" + mock_session.return_value.post.return_value = _reponse_stream( + _lignes_sse(USAGE_REEL) + ) + + chunks = list(_provider().generate_stream("", "salut")) + + cloture = chunks[-1] + assert cloture["done"] is True + assert cloture["usage"] == USAGE_REEL + # Les chunks de contenu ne portent jamais d'usage (contrat OpenAI). + assert all(c["usage"] is None for c in chunks[:-1]) + # Une seule écriture en base, sur les valeurs RÉELLES du provider. + assert len(compteur_record_usage) == 1 + assert compteur_record_usage[0]["prompt_tokens"] == 67523 + assert compteur_record_usage[0]["completion_tokens"] == 412 + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_sans_usage_le_chunk_final_reste_none(self, mock_session, + compteur_record_usage): + """Provider ignorant `include_usage` → le chunk de clôture porte None. + L'estimation chars/4 calculée pour la base est écrite en base (une + fois), mais elle ne remonte JAMAIS au client.""" + mock_session.return_value.post.return_value = _reponse_stream( + _lignes_sse(None) + ) + + chunks = list(_provider().generate_stream("", "salut")) + + assert chunks[-1] == {"token": "", "done": True, "usage": None} + assert all(c["usage"] is None for c in chunks) + # Le repli d'estimation reste en base : exactement un appel. + assert len(compteur_record_usage) == 1 + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_flux_sans_done_emet_quand_meme_la_cloture(self, mock_session, + compteur_record_usage): + """Certains providers terminent le flux sans [DONE] : la clôture doit + partir quand même, avec l'usage réel s'il a été reçu.""" + mock_session.return_value.post.return_value = _reponse_stream( + _lignes_sse(USAGE_REEL, avec_done=False) + ) + + chunks = list(_provider().generate_stream("", "salut")) + + assert chunks[-1] == {"token": "", "done": True, "usage": USAGE_REEL} + + +class TestGenerateCanalLateralUsage: + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_sink_porte_l_usage_reel(self, mock_session, compteur_record_usage): + """Réponse bufferisée avec usage → le sink le reçoit TEL QUEL, la + valeur de retour (chaîne) ne change pas, et la base n'est écrite + qu'une fois.""" + mock_session.return_value.post.return_value = _reponse_json({ + "choices": [{"message": {"content": "bonjour"}}], + "usage": USAGE_REEL, + }) + + sink: dict = {} + resultat = _provider().generate("", "salut", _usage_sink=sink) + + assert resultat == "bonjour" + assert sink == {"usage": USAGE_REEL} + assert len(compteur_record_usage) == 1 + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_sans_sink_comportement_inchange(self, mock_session, + compteur_record_usage): + """Non-régression : aucun appelant existant ne passe de sink — le + comportement est strictement celui d'avant (chaîne, une écriture).""" + mock_session.return_value.post.return_value = _reponse_json({ + "choices": [{"message": {"content": "bonjour"}}], + "usage": USAGE_REEL, + }) + + assert _provider().generate("", "salut") == "bonjour" + assert len(compteur_record_usage) == 1 + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_sink_vide_quand_le_provider_ne_renvoie_pas_d_usage( + self, mock_session, compteur_record_usage): + """Pas d'usage dans la réponse HTTP → le sink reste VIDE. Aucune + estimation ne vient le remplir.""" + mock_session.return_value.post.return_value = _reponse_json({ + "choices": [{"message": {"content": "bonjour"}}], + }) + + sink: dict = {} + _provider().generate("", "salut", _usage_sink=sink) + + assert sink == {} + assert len(compteur_record_usage) == 0 + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_sink_ne_part_jamais_dans_le_payload(self, mock_session, + compteur_record_usage): + """Le préfixe \"_\" marque un champ interne : il ne doit jamais se + retrouver dans le corps HTTP envoyé au provider amont.""" + mock_session.return_value.post.return_value = _reponse_json({ + "choices": [{"message": {"content": "bonjour"}}], + "usage": USAGE_REEL, + }) + + _provider().generate("", "salut", _usage_sink={}) + + payload = mock_session.return_value.post.call_args.kwargs["json"] + assert "_usage_sink" not in json.dumps(payload) + + @patch("core.openai_compat_provider.SharedAsyncHTTPPool.get_client") + def test_generate_async_meme_contrat(self, mock_client, + compteur_record_usage): + """Le miroir async honore le même canal latéral.""" + import asyncio + + async def _run(): + mock_client.return_value.post = AsyncMock(return_value=_reponse_json({ + "choices": [{"message": {"content": "bonjour"}}], + "usage": USAGE_REEL, + })) + sink: dict = {} + resultat = await _provider().generate_async("", "salut", _usage_sink=sink) + assert resultat == "bonjour" + assert sink == {"usage": USAGE_REEL} + assert len(compteur_record_usage) == 1 + + asyncio.run(_run()) + + +# ────────────────────────────────────────────────────────────────── +# Bout en bout : route /v1/chat/completions réelle +# ────────────────────────────────────────────────────────────────── + +class TestBoutEnBoutStreaming: + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_stream_usage_reel_livre_tel_quel(self, mock_session, client_proxy, + compteur_record_usage): + """LE test central : un provider simulé renvoie un chunk final + `usage` réel → la requête streamée livre au client un `usage` portant + EXACTEMENT les valeurs du provider, sans aucune valeur recalculée.""" + mock_session.return_value.post.return_value = _reponse_stream( + _lignes_sse(USAGE_REEL) + ) + + reponse = client_proxy.post("/v1/chat/completions", + json=_requete(stream=True)) + + assert reponse.status_code == 200 + chunks = _chunks_sse(reponse.text) + porteurs = [c for c in chunks if "usage" in c] + assert len(porteurs) == 1, "un seul chunk doit porter l'usage" + assert porteurs[0]["usage"] == USAGE_REEL + assert porteurs[0] is chunks[-1], "l'usage part sur le chunk final" + # Le flux de contenu est intact. + deltas = [ + c["choices"][0]["delta"].get("content") + for c in chunks if c["choices"][0]["delta"].get("content") + ] + assert deltas == ["Bon", "jour"] + # Pas de double-compte : une seule écriture en base. + assert len(compteur_record_usage) == 1 + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_stream_sans_usage_champ_absent(self, mock_session, client_proxy, + compteur_record_usage): + """Provider ignorant `include_usage` → aucun chunk ne porte `usage` : + pas de zéro, pas d'estimation — le repli calculé pour la base n'en + sort jamais.""" + mock_session.return_value.post.return_value = _reponse_stream( + _lignes_sse(None) + ) + + reponse = client_proxy.post("/v1/chat/completions", + json=_requete(stream=True)) + + chunks = _chunks_sse(reponse.text) + assert chunks, "le flux doit contenir au moins un chunk" + assert all("usage" not in c for c in chunks), ( + f"usage inventé : {[c.get('usage') for c in chunks if 'usage' in c]!r}" + ) + # L'estimation de repli reste en base, exactement une fois. + assert len(compteur_record_usage) == 1 + + +class TestBoutEnBoutBufferise: + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_reponse_texte_usage_transmis_via_sink(self, mock_session, + client_proxy, + compteur_record_usage): + """Chemin bufferisé, réponse CHAÎNE : l'usage réel transite par le + canal latéral et arrive dans la réponse OpenAI — le cas qui était + structurellement perdu avant cette PR.""" + mock_session.return_value.post.return_value = _reponse_json({ + "choices": [{"message": {"content": "bonjour"}}], + "usage": USAGE_REEL, + }) + + reponse = client_proxy.post("/v1/chat/completions", + json=_requete(stream=False)) + + assert reponse.status_code == 200 + assert reponse.json()["usage"] == USAGE_REEL + assert len(compteur_record_usage) == 1 + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_reponse_texte_sans_usage_champ_omis(self, mock_session, + client_proxy, + compteur_record_usage): + """Pas d'usage amont → la clé est absente de la réponse (jamais de + zéro ni d'estimation).""" + mock_session.return_value.post.return_value = _reponse_json({ + "choices": [{"message": {"content": "bonjour"}}], + }) + + corps = client_proxy.post("/v1/chat/completions", + json=_requete(stream=False)).json() + + assert "usage" not in corps + assert corps["choices"][0]["message"]["content"] == "bonjour" + assert len(compteur_record_usage) == 0 + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_stream_avec_outils_usage_reel_sur_chunk_final(self, mock_session, + client_proxy, + compteur_record_usage): + """`stream=true` + `tools` — le cas normal d'un client d'IDE + agentique : la requête reste bufferisée et le chunk final porte + l'usage réel remonté par le sink.""" + mock_session.return_value.post.return_value = _reponse_json({ + "choices": [{"message": { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": {"name": "read_file", "arguments": "{}"}, + }], + }}], + "usage": USAGE_REEL, + }) + + reponse = client_proxy.post("/v1/chat/completions", + json=_requete(stream=True, outils=True)) + + chunks = _chunks_sse(reponse.text) + final = chunks[-1] + assert final["choices"][0]["finish_reason"] == "tool_calls" + assert final["usage"] == USAGE_REEL + assert all("usage" not in c for c in chunks[:-1]) + assert len(compteur_record_usage) == 1 diff --git a/tests/unit/test_quota_alerte_t343.py b/tests/unit/test_quota_alerte_t343.py new file mode 100644 index 0000000..321bf91 --- /dev/null +++ b/tests/unit/test_quota_alerte_t343.py @@ -0,0 +1,224 @@ +""" +tests/unit/test_quota_alerte_t343.py — #T343-B : hystérésis CONSÉCUTIVE et +alerte rejouée si la notification échoue. + +Deux défauts mesurés dans la machine à états de `core/quota_alert.py` : + - (b1) un franchissement pendant une sortie en cours ne remettait pas + `cycles_au_dessus` à zéro : avec `sortie_cycles = 2`, un cycle au-dessus, + cinq cycles SOUS le seuil, puis un cycle au-dessus levait l'alerte, alors + qu'à aucun moment le système n'est resté deux cycles CONSÉCUTIFS au-dessus ; + - (b2) `_ecrire_etat` persistait `en_alerte = 1` AVANT la notification : si + Home Assistant était injoignable, la notification était perdue et le cycle + suivant, « déjà en alerte », ne renotifiait JAMAIS. + +Garde-fous de #T334 exercés ici : un solde bas dix cycles produit UNE alerte, +pas dix ; la réémission est bornée à l'échec de notification. +Aucun réseau : `get_quota_summary` et `_send_ha_notification` sont mockés. +""" +import asyncio +from unittest.mock import AsyncMock + +import pytest + +from core import runtime_db + + +@pytest.fixture +def base_alerte(tmp_path): + """Base SQLite temporaire au vrai schéma ; restaurée après le test.""" + original = runtime_db.get_db_path() + db = tmp_path / "quota_alertes.db" + runtime_db.override_db_path(str(db)) + runtime_db.get_connection().close() # déclenche _init_schema (schéma réel) + yield db + runtime_db.override_db_path(original) + + +@pytest.fixture +def resume_sequence(monkeypatch): + """File de résumés : chaque cycle consomme l'élément suivant.""" + etat = {"cycles": []} + + def _resume(): + if etat["cycles"]: + return etat["cycles"].pop(0) + return {"keys": []} + + monkeypatch.setattr("core.models_db.get_quota_summary", _resume) + return etat + + +def sous_tension(): + """Une clé au solde sous le seuil (0,35 $ <= 2 $).""" + return {"keys": [{ + "api_key_id": "DEEPSEEK_API_KEY", + "provider_id": "deepseek", + "saturation_pct": None, + "external_balance_usd": 0.35, + "external_status": "warning", + }]} + + +def normale(): + """Aucune clé sous tension.""" + return {"keys": []} + + +@pytest.fixture +def notifications(monkeypatch): + """Compte les notifications HA (aucun réseau).""" + compteur = {"appels": 0, "messages": []} + + async def _fake_send(title, message): + compteur["appels"] += 1 + compteur["messages"].append(message) + return True + + monkeypatch.setattr( + "core.daemon_loop._send_ha_notification", AsyncMock(side_effect=_fake_send) + ) + return compteur + + +# ── (b1) La sortie d'alerte exige des cycles CONSÉCUTIFS ───────────────────── + +@pytest.mark.asyncio +async def test_franchissement_pendant_la_sortie_annule_la_sortie( + base_alerte, resume_sequence, notifications, monkeypatch +): + """Au-dessus / sous / au-dessus avec sortie_cycles = 2 → l'alerte n'est PAS + levée : la sortie exige deux cycles consécutifs au-dessus du seuil.""" + monkeypatch.setenv("QUOTA_ALERTE_SORTIE_CYCLES", "2") + from core.quota_alert import executer_cycle_alerte + + resume_sequence["cycles"] = ( + [sous_tension()] * 2 # franchissement : alerte au 2e cycle + + [normale()] # 1 cycle au-dessus : sortie en cours + + [sous_tension()] * 5 # re-franchissement : annule la sortie + + [normale()] # 1 cycle au-dessus : toujours PAS 2 consécutifs + ) + + comptes_rendus = [] + for _ in range(9): + comptes_rendus.append(await executer_cycle_alerte()) + + assert comptes_rendus[-1]["en_alerte"] is True, ( + "l'alerte ne doit PAS être levée : aucun moment avec 2 cycles " + "consécutifs au-dessus du seuil" + ) + + # Deux cycles au-dessus CONSÉCUTIFS lèvent enfin l'alerte. + resume_sequence["cycles"] = [normale(), normale()] + cr = await executer_cycle_alerte() + cr = await executer_cycle_alerte() + assert cr["en_alerte"] is False + + +# ── (b2) Une notification échouée est retentée au cycle suivant ────────────── + +@pytest.mark.asyncio +async def test_notification_echouee_retentee_jusqu_au_succes( + base_alerte, resume_sequence, monkeypatch +): + """Test central : première notification en échec → le cycle suivant retente ; + notification en succès → le cycle suivant ne renotifie pas.""" + compteur = {"appels": 0, "echecs_restants": 1} + + async def _fake_send(title, message): + compteur["appels"] += 1 + if compteur["echecs_restants"] > 0: + compteur["echecs_restants"] -= 1 + return False + return True + + monkeypatch.setattr( + "core.daemon_loop._send_ha_notification", AsyncMock(side_effect=_fake_send) + ) + from core.quota_alert import executer_cycle_alerte + + resume_sequence["cycles"] = [sous_tension()] * 4 + + comptes_rendus = [] + for _ in range(4): + comptes_rendus.append(await executer_cycle_alerte()) + + # Cycle 2 : franchissement confirmé, notification en échec → l'état + # « en alerte » n'est PAS persisté, la machine doit rester prête à retenter. + assert comptes_rendus[1]["nouvelle_alerte"] is True + assert comptes_rendus[1]["notifiee"] is False + assert comptes_rendus[1]["en_alerte"] is False + # Cycle 3 : retentée → succès → l'alerte est posée. + assert comptes_rendus[2]["nouvelle_alerte"] is True + assert comptes_rendus[2]["notifiee"] is True + assert comptes_rendus[2]["en_alerte"] is True + # Cycle 4 : déjà notifiée → plus rien. + assert comptes_rendus[3]["nouvelle_alerte"] is False + assert comptes_rendus[3]["notifiee"] is False + assert comptes_rendus[3]["en_alerte"] is True + assert compteur["appels"] == 2, ( + f"{compteur['appels']} notifications : une seule retentée après l'échec" + ) + + +# ── Concurrence : deux cycles en parallèle ne renotifient pas ─────────────── + +@pytest.mark.asyncio +async def test_cycles_concurrents_une_seule_notification( + base_alerte, resume_sequence, monkeypatch +): + """Boucle de fond + bouton IHM en parallèle → UNE seule notification : + sans verrou, le second cycle lirait un état pas encore persisté pendant + l'aller-retour de notification et renotifierait (revue Bugbot PR #302).""" + compteur = {"appels": 0} + + async def _fake_send_lent(title, message): + compteur["appels"] += 1 + await asyncio.sleep(0.1) # round-trip HA simulé : la fenêtre de concurrence + return True + + monkeypatch.setattr( + "core.daemon_loop._send_ha_notification", + AsyncMock(side_effect=_fake_send_lent), + ) + from core.quota_alert import executer_cycle_alerte + + # Pré-condition : un premier cycle a posé cycles_sous_seuil=1 (pas encore + # en alerte, confirmation_cycles=2). + resume_sequence["cycles"] = [sous_tension()] + await executer_cycle_alerte() + + # Deux cycles partent ENSEMBLE : sans verrou, les deux liraient l'état + # périmé (en_alerte=0) et notifieraient chacun de leur côté. + resume_sequence["cycles"] = [sous_tension(), sous_tension()] + comptes_rendus = await asyncio.gather( + executer_cycle_alerte(), executer_cycle_alerte() + ) + + assert compteur["appels"] == 1, ( + f"{compteur['appels']} notifications pour un seul franchissement — " + "la garantie « une alerte par franchissement » de #T334 est rompue" + ) + assert sum(1 for c in comptes_rendus if c["nouvelle_alerte"]) == 1 + assert all(c["en_alerte"] for c in comptes_rendus) + + +# ── Non-régression #T334 : UNE alerte, pas une par cycle ───────────────────── + +@pytest.mark.asyncio +async def test_dix_cycles_sous_le_seuil_une_seule_notification( + base_alerte, resume_sequence, notifications +): + """Un solde qui reste bas dix cycles (notification réussie) → UNE seule + notification : la réémission est bornée à l'échec, jamais une par cycle.""" + from core.quota_alert import executer_cycle_alerte + + resume_sequence["cycles"] = [sous_tension()] * 10 + + comptes_rendus = [] + for _ in range(10): + comptes_rendus.append(await executer_cycle_alerte()) + + assert notifications["appels"] == 1 + assert [c["nouvelle_alerte"] for c in comptes_rendus] == \ + [False, True] + [False] * 8 + assert comptes_rendus[-1]["en_alerte"] is True diff --git a/tests/unit/test_recolte_dreamcoder.py b/tests/unit/test_recolte_dreamcoder.py new file mode 100644 index 0000000..b55c2bf --- /dev/null +++ b/tests/unit/test_recolte_dreamcoder.py @@ -0,0 +1,133 @@ +""" +tests/unit/test_recolte_dreamcoder.py — Récolte des branches de nuit. + +DreamCoder produit des branches `task/*` sur le clone du Deck, qui ne peut pas +les publier (clé de déploiement en lecture seule). `scripts/recolte_dreamcoder.py` +fait la dernière marche depuis le PC. Ces tests figent les deux propriétés qui +comptent : on ne confond pas « rien produit » avec « je n'ai pas pu regarder », +et on ne ramasse que des branches de tâche. +""" + +import importlib.util +from pathlib import Path +from unittest.mock import patch + +import pytest + +# Le script vit dans scripts/ (pas un paquet importable) : chargement par chemin. +_CHEMIN = Path(__file__).resolve().parents[2] / "scripts" / "recolte_dreamcoder.py" +_spec = importlib.util.spec_from_file_location("recolte_dreamcoder", _CHEMIN) +recolte = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(recolte) + + +# ── Reconnaissance des branches ────────────────────────────────────────────── + +@pytest.mark.parametrize("nom", [ + "task/42_1787000000", + "task/1_1786519469", +]) +def test_branche_de_tache_reconnue(nom): + assert recolte._RE_BRANCHE.match(nom) + + +@pytest.mark.parametrize("nom", [ + "agent/run_chat_0ab605f939_1786564679", # branche de session vocale du Deck + "master", + "task/abc_1787000000", # id non numérique + "task/42", # horodatage manquant + "feat/task/42_1787000000", # préfixé : pas une branche de tâche +]) +def test_branche_hors_perimetre_ignoree(nom): + assert recolte._RE_BRANCHE.match(nom) is None + + +def test_liste_ne_garde_que_les_branches_de_tache(): + """Le clone du Deck porte aussi des branches de session : elles ne se récoltent pas.""" + sortie = "master\nagent/run_chat_0ab605f939_1786564679\ntask/7_1787000123\ntask/9_1787000456" + + with patch.object(recolte, "_ssh_deck", return_value=(0, sortie, "")): + branches = recolte.lister_branches_taches("h", "c", "/clone", "master") + + assert [b["branche"] for b in branches] == ["task/7_1787000123", "task/9_1787000456"] + assert [b["task_id"] for b in branches] == [7, 9] + + +# ── La panne ne se déguise pas en absence de travail ───────────────────────── + +def test_deck_injoignable_leve_au_lieu_de_rendre_une_liste_vide(): + """ + Retourner [] ici ferait dire au script « rien produit cette nuit » avec un + code de sortie 0 — un Deck éteint passerait pour une nuit calme. + """ + with patch.object(recolte, "_ssh_deck", return_value=(255, "", "Connection refused")): + with pytest.raises(RuntimeError, match="lecture des branches impossible"): + recolte.lister_branches_taches("h", "c", "/clone", "master") + + +def test_code_de_sortie_non_nul_quand_le_deck_ne_repond_pas(): + """Bout du fil : l'échec doit être visible depuis un shell ou un cron.""" + class _Args: + hote, cle, clone, prod, base = "h", __file__, "/clone", "/prod", "master" + depot, remote, dry_run = ".", "origin", True + + with patch.object(recolte, "_ssh_deck", return_value=(255, "", "Connection refused")): + assert recolte.recolter(_Args()) == 1 + + +def test_aucune_branche_est_un_succes(): + """Backlog drainé sans production : ce n'est pas une erreur.""" + class _Args: + hote, cle, clone, prod, base = "h", __file__, "/clone", "/prod", "master" + depot, remote, dry_run = ".", "origin", True + + with patch.object(recolte, "_ssh_deck", return_value=(0, "master\n", "")): + assert recolte.recolter(_Args()) == 0 + + +# ── Corps de PR ────────────────────────────────────────────────────────────── + +def test_corps_de_pr_porte_le_contexte_d_execution(): + rapport = { + "title": "Standardiser query_deepseek sur generate_async", + "summary": "Deux appels migrés, 4 tests ajoutés.", + "cost_usd": 0.0123, + "tokens_used": 8421, + } + corps = recolte.corps_de_pr(7, rapport) + + assert "Tâche #7" in corps + assert "Standardiser query_deepseek" in corps + assert "$0.0123" in corps + assert "8421" in corps + # La CI GitHub est morte (#T382) : la relecture humaine est la seule barrière. + assert "pytest tests/unit" in corps + + +def test_corps_de_pr_survit_a_un_rapport_absent(): + """Le rapport vit dans le runtime de prod : il peut manquer sans bloquer la PR.""" + corps = recolte.corps_de_pr(11, {}) + + assert "Tâche #11" in corps + assert "inconnu" in corps + assert "(pas de résumé)" in corps + + +# ── Rejouabilité ───────────────────────────────────────────────────────────── + +def test_pr_existante_detectee(): + """Relancer la récolte ne doit pas ouvrir une seconde PR sur la même branche.""" + with patch.object(recolte, "_run", + return_value=(0, '[{"url":"https://github.com/x/y/pull/1"}]', "")): + assert recolte.pr_existante(".", "task/7_1787000123") == "https://github.com/x/y/pull/1" + + +def test_pr_existante_absente(): + with patch.object(recolte, "_run", return_value=(0, "[]", "")): + assert recolte.pr_existante(".", "task/7_1787000123") is None + + +def test_pr_existante_tolere_une_sortie_illisible(): + """`gh` absent ou non authentifié ne doit pas casser la récolte.""" + with patch.object(recolte, "_run", return_value=(1, "pas du json", "erreur gh")): + assert recolte.pr_existante(".", "task/7_1787000123") is None diff --git a/tests/unit/test_regression_prod_17_08.py b/tests/unit/test_regression_prod_17_08.py new file mode 100644 index 0000000..eef247f --- /dev/null +++ b/tests/unit/test_regression_prod_17_08.py @@ -0,0 +1,308 @@ +""" +[17/08] Banc de régression croisé — six correctifs livrés en prod (commit 6b7fcb8). + +Ce fichier fige ENSEMBLE les décisions des six correctifs mergés le 17/08. Sa +valeur n'est pas de redoubler les tests unitaires de chaque correctif (ils +existent déjà) mais de CROISER les chemins : chaque phrase réelle y est jugée +par les quatre décideurs à la fois, pour verrouiller qu'un correctif n'en a pas +cassé un autre. + + #T358 services/ha_state_query._est_question_generale + #T363 services.execute_service.match_ha_command (garde-fou « question ≠ ordre ») + #T364 services.ha_state_query.match_ha_state_query (le résolveur ne devine plus) + #T365 core.vocal_host.classify_vocal_intent (_CALENDAR_MARKERS) + #T366 core.vocal_tts_cache.sanitize_discussion_tts (LaTeX → « la formule ») + #T367 services.ha_weather_query.match_weather_query (météo depuis l'entité HA) + +Règles du banc : +- On teste la DÉCISION, jamais l'effet. `match_ha_command` est un pur matcher en + dry-run : il retourne un `HACommandMatch` sans rien exécuter (l'exécution + appartient à `resolve_ha_command_for_execute` et n'est jamais appelée ici). + Aucun test n'allume, n'éteint ni ne lit rien. +- Cas non résolu figé en `xfail` (#T271) : la transcription STT est trop + dégradée pour être reconnue par quoi que ce soit — le jour où elle le sera, + l'XPASS strict le signalera. +""" + +import pytest + +from core.vocal_host import VocalIntent, classify_vocal_intent +from core.vocal_stt_normalize import normalize_vocal_stt +from core.vocal_tts_cache import sanitize_discussion_tts +from services.execute_service import match_ha_command, normalize_ha_command_prompt +from services.ha_state_query import _est_question_generale, match_ha_state_query +from services.ha_weather_query import WeatherHorizon, match_weather_query + +# ───────────────────────────────────────────────────────────────────── +# Table croisée : une ligne = une phrase réelle → les QUATRE décisions. +# Colonnes : (phrase, tâche d'origine, commande, état, météo, intention). +# « commande » est le service HA attendu (None = aucune commande) ; « état » +# le kind de lecture d'état (None = aucune) ; « météo » l'horizon (None = +# aucune) ; « intention » le verdict de classify_vocal_intent. +# ───────────────────────────────────────────────────────────────────── + +_CAS = ( + # [#T358] Question de culture générale contenant un nom d'appareil : elle + # rendait « Le volet est partiellement ouvert. » en 58 ms. Aujourd'hui elle + # ne déclenche RIEN de domotique : elle poursuit jusqu'au chat. + ("en une phrase, c'est quoi un volet roulant ?", "#T358", + None, None, None, VocalIntent.CHAT), + # [#T363] Questions réelles qui actionnaient la maison : la première + # déclenchait light.turn_on, la seconde light.turn_off (fuzzy 0,82). Elles + # restent des LECTURES d'état, jamais des commandes — ni une météo. + ("est-ce que la lumière du salon est allumée ?", "#T363", + None, "light", None, VocalIntent.CHAT), + ("État de la lumière de la chambre.", "#T363", + None, "light", None, VocalIntent.CHAT), + # [#T363] Subtilité vérifiée : « est-elle » est réécrit en « éteins » par le + # STT (normalize_vocal_stt), mais le garde-fou juge le prompt BRUT : avec le + # point d'interrogation, c'est une question → aucune commande. Sans « ? », + # la transcription dégradée garde sa lecture d'ordre (voir test dédié). + ("Est-elle la lumière de la chambre ?", "#T363", + None, None, None, VocalIntent.CHAT), + # [#T364] « Combien fait 17 fois 24 ? » rendait « Il fait 22 degrés dans le + # salon. » en 38 ms : « combien » seul ne suffit plus à déclencher une + # température. + ("Combien fait 17 fois 24 ?", "#T364", + None, None, None, VocalIntent.CHAT), + # [#T371] La chambre A un capteur (`sensor.bedroom_temperature`). + # #T364 l'avait figée à `None` (pièce « sans capteur ») : c'était faux. + # Elle répond désormais, avec SON capteur, pas celui du salon. + ("il fait combien dans la chambre ?", "#T371", + None, "temperature", None, VocalIntent.WEB), + # [#T364] « Quel est la température du salon ? » (STT abîmé) répondait sur + # la LUMIÈRE du salon. Aujourd'hui : lecture de température, point. + ("quel est la temperature du salon ?", "#T364", + None, "temperature", None, VocalIntent.CHAT), + # [#T367] L'intérieur n'est PAS la météo : le classifieur rapide voit + # « il fait combien » → WEB (score 0,5), mais match_weather_query refuse la + # pièce nommée et la lecture d'état intérieure gagne (étape 1b avant 1c + # dans handle_discussion). L'intention WEB seule ne fait pas la météo. + ("il fait combien dans le salon ?", "#T367", + None, "temperature", None, VocalIntent.WEB), + # [#T367] Les vraies demandes météo passent par l'entité HA de la maison. + ("il fait combien dehors", "#T367", + None, None, WeatherHorizon.NOW, VocalIntent.WEB), + ("quel temps fait-il aujourd'hui ?", "#T367", + None, None, WeatherHorizon.TODAY, VocalIntent.WEB), + ("est-ce qu'il va pleuvoir demain ?", "#T367", + None, None, WeatherHorizon.TOMORROW, VocalIntent.WEB), + # [#T365] Le champ horaire du travail atteint le calendrier — formulations + # accentuées telles que le STT les produit (les tests d'origine, écrits sans + # accent, ont laissé passer le bug des marqueurs non accentués, #T383). + ("À quelle heure je commence à travailler demain ?", "#T365", + None, None, None, VocalIntent.CALENDAR), + ("je finis à quelle heure demain ?", "#T365", + None, None, None, VocalIntent.CALENDAR), + ("c'est quand mon prochain jour de repos ?", "#T365", + None, None, None, VocalIntent.CALENDAR), + ("j'ai quoi de prévu ce week-end ?", "#T365", + None, None, None, VocalIntent.CALENDAR), + ("quel est mon planning de la semaine ?", "#T365", + None, None, None, VocalIntent.CALENDAR), + # [#T365] Garde-fou des marqueurs longs : « je commence à comprendre » reste + # du chat, il ne bascule pas au calendrier. + ("je commence à comprendre la relativité", "#T365", + None, None, None, VocalIntent.CHAT), + # [#T363] L'ordre explicite prime toujours sur la forme interrogative : + # l'infinitif d'action fait de la question un ordre poli. Dry-run : le match + # retourné n'est JAMAIS exécuté. + ("Peux-tu allumer la lumière du salon ?", "#T363", + "light.turn_on", None, None, VocalIntent.CHAT), + ("remonte le volet", "#T363", + "script.blind_action", None, None, VocalIntent.CHAT), + # [#T363] Sans point d'interrogation, « est-elle » (transcription STT + # dégradée d'« éteins ») garde sa lecture d'ordre — le « ? » est ce qui + # tranche entre question et commande. + ("Est-elle la lumière de la chambre", "#T363", + "light.turn_off", None, None, VocalIntent.CHAT), +) + +_IDS_CAS = [ + f"{tache} | {phrase[:44]}" for phrase, tache, *_ in _CAS +] + + +@pytest.mark.parametrize("phrase,tache,cmd,etat,meteo,intent", _CAS, ids=_IDS_CAS) +def test_chaque_phrase_prend_la_bonne_decision(phrase, tache, cmd, etat, meteo, intent): + """La phrase aboutit à la décision attendue — et à AUCUNE autre. + + Chaque ligne croise les quatre décideurs : un correctif qui déborde sur le + chemin d'un autre fait échouer sa ligne. + """ + c = match_ha_command(phrase) + assert (c.service if c else None) == cmd, ( + f"[{tache}] {phrase!r} → commande {c.service if c else None!r}" + ) + e = match_ha_state_query(phrase) + assert (e.kind if e else None) == etat, ( + f"[{tache}] {phrase!r} → lecture d'état {e.kind if e else None!r}" + ) + m = match_weather_query(phrase) + assert (m.horizon.value if m else None) == meteo, ( + f"[{tache}] {phrase!r} → météo {m.horizon.value if m else None!r}" + ) + i, score = classify_vocal_intent(phrase) + assert i == intent, f"[{tache}] {phrase!r} → intention {i.name} (score {score})" + + +# ───────────────────────────────────────────────────────────────────── +# [#T358] _est_question_generale, testée directement (même appel que +# match_ha_state_query : texte normalisé + ensemble de mots). +# ───────────────────────────────────────────────────────────────────── + +def _norm(prompt: str) -> tuple[str, set[str]]: + """Même normalisation que match_ha_state_query (l. 149-153).""" + norm = normalize_ha_command_prompt(prompt) + return norm, set(norm.split()) + + +@pytest.mark.parametrize("phrase", [ + # Phrase exacte mesurée en prod (réponse « Le volet est partiellement + # ouvert. » en 58 ms, deux fois de suite) et ses voisines généralisables. + "en une phrase, c'est quoi un volet roulant ?", + "explique-moi comment marche une ampoule LED", + "comment fonctionne une clim ?", + "a quoi sert un thermostat ?", + "quelle difference entre un store et un volet ?", +]) +def test_t358_question_de_culture_generale_detectee(phrase): + norm, word_set = _norm(phrase) + assert _est_question_generale(norm, word_set) is True + + +@pytest.mark.parametrize("phrase", [ + # L'adjectif d'état tranche en faveur de l'installation, même avec un + # article indéfini : ce sont des lectures d'état, pas des définitions. + "est-ce que la lumiere du salon est allumee ?", + "le volet est ouvert ?", + "la clim est allumee ?", +]) +def test_t358_la_question_detat_reelle_nest_pas_generale(phrase): + norm, word_set = _norm(phrase) + assert _est_question_generale(norm, word_set) is False + + +# ───────────────────────────────────────────────────────────────────── +# [#T363] Subtilité du garde-fou : le prompt BRUT, jamais le normalisé. +# ───────────────────────────────────────────────────────────────────── + +def test_t363_le_garde_fou_juge_le_brut_pas_le_normalise(): + """normalize_vocal_stt réécrit « est-elle » en « éteins » (vocal_stt_normalize + l. 38) : sur le texte normalisé, la question devient littéralement un ordre + d'extinction. Si le garde-fou jugeait ce texte-là, il ne verrait plus jamais + la forme interrogative — d'où le jugement sur le brut, point d'entrée du + correctif #T363. + """ + brut = "Est-elle la lumiere de la chambre ?" + assert "eteins" in normalize_vocal_stt(brut) # le STT a réécrit la question + assert match_ha_command(brut) is None # mais la question ne commande pas + + +def test_t363_sans_point_d_interrogation_la_transcription_degradee_commande(): + """Sans « ? », la lecture « ordre » est conservée : on ne perd pas de vraies + commandes mal transcrites. C'est le point d'interrogation qui tranche. + """ + cmd = match_ha_command("Est-elle la lumiere de la chambre") + assert cmd is not None and cmd.service == "light.turn_off" + + +# ───────────────────────────────────────────────────────────────────── +# [#T364] Le résolveur ne devine plus : l'entité visée est exacte. +# ───────────────────────────────────────────────────────────────────── + +def test_t364_la_temperature_du_salon_ne_repond_pas_sur_la_lumiere(): + """« Quel est la température du salon ? » répond la température du salon — + plus jamais « La lumière du salon est allumée. » (branche lumière, #T364).""" + q = match_ha_state_query("quel est la temperature du salon ?") + assert q is not None and q.kind == "temperature" + assert q.entity_id == "climate.living_room" + + +# ───────────────────────────────────────────────────────────────────── +# [#T365] Les marqueurs accentués — le point que les tests d'origine +# (écrits sans accent) ont laissé passer, corrigé par #T383. +# ───────────────────────────────────────────────────────────────────── + +def test_t365_les_formulations_accentuees_du_stt_atteignent_le_calendrier(): + """Le STT produit des accents ; _normalize_prompt les conserve. Les marqueurs + doivent exister dans les deux orthographes, faute de quoi ils ne matchent + rien en production (bug #T383, invisible dans les tests non accentués).""" + for phrase in ( + "À quelle heure je commence à travailler demain ?", + "je finis à quelle heure demain ?", + "je termine à quelle heure ?", + "c'est quand mon prochain jour de repos ?", + "j'ai quoi de prévu ce week-end ?", + "quel est mon planning de la semaine ?", + ): + intent, score = classify_vocal_intent(phrase) + assert intent == VocalIntent.CALENDAR, phrase + assert score > 0, phrase + + +# ───────────────────────────────────────────────────────────────────── +# [#T366] Le LaTeX ne part plus à la synthèse vocale. +# ───────────────────────────────────────────────────────────────────── + +def test_t366_le_latex_devient_la_formule_lisible_tts(): + """Formule exacte rendue le 17/08 pour « donne-moi la formule de la + relativité générale » : le TTS l'ânonnait symbole par symbole.""" + formule = ( + r"L'équation s'écrit \(G_{\mu\nu} + \Lambda\,g_{\mu\nu} = " + r"\frac{8\pi G}{c^{4}}\,T_{\mu\nu}\) et c'est tout." + ) + out = sanitize_discussion_tts(formule) + assert "la formule" in out + assert "\\" not in out, "aucune commande LaTeX résiduelle ne doit être lue" + + +def test_t366_les_prix_en_dollars_survivent_au_nettoyage(): + """« $…$ » n'est neutralisé que s'il contient une marque LaTeX : un prix ne + doit jamais être avalé.""" + assert "5 $" in sanitize_discussion_tts("Ce café coûte 5 $, c'est raisonnable.") + assert "10 $ et 20 $" in sanitize_discussion_tts( + "Compte entre 10 $ et 20 $ pour le trajet." + ) + + +# ───────────────────────────────────────────────────────────────────── +# [#T367] « il fait combien dans le salon ? » n'est PAS une météo — +# croisement explicite avec la lecture d'état intérieure (#T364). +# ───────────────────────────────────────────────────────────────────── + +def test_t367_la_question_interieure_nest_pas_une_meteo(): + """La pièce nommée rend la main à la température intérieure : aucune météo + ne doit partir, la lecture d'état reste la décision.""" + assert match_weather_query("il fait combien dans le salon ?") is None + q = match_ha_state_query("il fait combien dans le salon ?") + assert q is not None and q.kind == "temperature" + + +# ───────────────────────────────────────────────────────────────────── +# [#T271] Cas NON résolu, figé comme tel : la transcription STT dégradée +# n'est reconnue par aucun chemin. xfail strict — pas skip, pas d'omission. +# ───────────────────────────────────────────────────────────────────── + +@pytest.mark.xfail( + strict=True, + reason=( + "#T271 : « C'est à qui l'heure que j'en boche ? » n'est reconnu par aucun " + "chemin — la transcription STT est trop dégradée pour qu'un marqueur en " + "sorte (vérifié le 17/08 : commande, état, météo, calendrier → tous None). " + "Tant que rien ne la reconnaît, ce test échoue (xfail attendu) ; le jour " + "où le STT ou le moteur la reconnaît, il passe et l'XPASS strict alerte " + "pour retirer le xfail." + ), +) +def test_t271_la_transcription_degradee_sera_reconnue_un_jour(): + """Aujourd'hui, aucune décision n'est prise : la phrase part au chat, qui ne + peut pas deviner une heure de travail dans ce bruit. Ce n'est pas acceptable + pour toujours — le test documente l'ambition, pas la résignation.""" + phrase = "C'est à qui l'heure que j'en boche ?" + assert any(( + match_ha_command(phrase) is not None, + match_ha_state_query(phrase) is not None, + match_weather_query(phrase) is not None, + classify_vocal_intent(phrase)[0] != VocalIntent.CHAT, + )) diff --git a/tests/unit/test_seed_routing_rules_dashscope.py b/tests/unit/test_seed_routing_rules_dashscope.py index 1825aca..441e99c 100644 --- a/tests/unit/test_seed_routing_rules_dashscope.py +++ b/tests/unit/test_seed_routing_rules_dashscope.py @@ -54,33 +54,24 @@ def _regles_dashscope(chemin): class TestReglesDashScope: - """Le seed expose des recommandations vers le forfait DashScope Coding Plan.""" + """D-8 : plus aucune recommandation vers Dashscope ; les types code restent d'autres providers.""" - def test_au_moins_une_regle_dashscope(self, base_peuplee): - """Au moins une règle pointe vers un modèle du forfait DashScope.""" + def test_aucune_regle_dashscope(self, base_peuplee): + """Le seed ne recommande plus aucun modèle dashscope/*.""" regles = _regles_dashscope(base_peuplee) - assert len(regles) >= 1, "aucune règle de routage vers dashscope/*" + assert regles == [], f"règles Dashscope encore présentes : {regles}" def test_couverture_code_et_agentique(self, base_peuplee): - """Les types de tâche code et agentique sont couverts.""" - types = {r["task_type"] for r in _regles_dashscope(base_peuplee)} - for attendu in ("code_generation", "code_complexe", "code_revision", "refactoring", "agentique"): - assert attendu in types, f"règle {attendu} manquante pour dashscope/*" - - def test_regles_dashscope_non_orphelines(self, base_peuplee): - """Chaque modèle recommandé dashscope/* existe dans la table models.""" + """Les types de tâche code et agentique restent couverts (repli D-8).""" conn = sqlite3.connect(base_peuplee) - for r in _regles_dashscope(base_peuplee): - modele = conn.execute( - "SELECT id FROM models WHERE id = ?", (r["recommended_model"],) - ).fetchone() - assert modele is not None, ( - f"règle {r['task_type']} → {r['recommended_model']} orpheline (modèle absent du catalogue)" - ) - assert r["provider_id"] == "dashscope", ( - f"règle {r['task_type']} : provider_id {r['provider_id']} incohérent" - ) + conn.row_factory = sqlite3.Row + types = { + r["task_type"] + for r in conn.execute("SELECT task_type FROM routing_rules").fetchall() + } conn.close() + for attendu in ("code_generation", "code_complexe", "code_revision", "refactoring", "agentique"): + assert attendu in types, f"règle {attendu} manquante après repli D-8" def test_foreign_key_check_propre(self, base_peuplee): """Aucune violation de clé étrangère après le seed complet.""" diff --git a/tests/unit/test_stream_discussion_outils_t346.py b/tests/unit/test_stream_discussion_outils_t346.py new file mode 100644 index 0000000..0f38d26 --- /dev/null +++ b/tests/unit/test_stream_discussion_outils_t346.py @@ -0,0 +1,341 @@ +"""test_stream_discussion_outils_t346.py — #T346, outils sur le chemin streamé. + +Le mode chat streamé (98 % du trafic) appelait le provider en direct, SANS +aucun outil : ceux-ci ne vivaient que sur le chemin non streamé +(`run_vocal_tool_loop`). Ce test couvre la réutilisation de cette boucle sur +le chemin streamé — pas de seconde boucle d'outils — et les événements de +cycle de vie attendus par `ToolCallDisplay` (appel → résultat ou erreur, avec +durée). + +Aucun appel réseau réel ici : les providers sont des doubles, le dispatch +d'outils est mocké, la sonde d'hôte aussi, et `inject_project_context=False` +évite tout appel RAG/BDD. +""" +import asyncio +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from core.vocal_tools import run_vocal_tool_loop +from services import pipeline_service + +_APPEL_OUTIL = { + "role": "assistant", + "content": None, + "tool_calls": [{ + "id": "call_1", + "type": "function", + "function": { + "name": "ha_get_state", + "arguments": '{"entity_id": "sensor.salon_temperature"}', + }, + }], +} + +_REPONSE_FINALE = { + "role": "assistant", + "content": "Il fait 21,5 degrés dans le salon.", +} + + +class OpenAICompatibleProvider: + """Double factice : le nom de classe déclenche + `provider_supports_openai_tools`, comme le vrai provider factorisé.""" + + def __init__(self, reponses): + self.base_url = "https://api.cerebras.ai/v1/chat/completions" + self.reponses = list(reponses) + self.appels: list[list[dict]] = [] + + def generate(self, system_prompt, user_prompt, **kwargs): + self.appels.append([dict(m) for m in kwargs.get("messages") or []]) + return self.reponses.pop(0) + + def generate_stream(self, system_prompt, user_prompt, **kwargs): + # Ne doit PAS être emprunté quand la boucle d'outils répond. + yield {"token": "FLUX_DIRECT", "done": False, "usage": None} + yield {"token": "", "done": True, "usage": None} + + +class _ProviderSansOutils: + """Double factice NON détecté compatible outils (nom de classe neutre).""" + + def __init__(self): + self.base_url = "https://generativelanguage.googleapis.com/v1" + + def generate_stream(self, system_prompt, user_prompt, **kwargs): + yield {"token": "Bonjour.", "done": False, "usage": None} + yield {"token": "", "done": True, "usage": None} + + +class _GatewayFactice: + def __init__(self, providers): + self._providers = providers + + def get_provider(self, name): + if name not in self._providers: + raise ValueError(f"Provider LLM inconnu : {name}") + return self._providers[name] + + @staticmethod + def _est_hote_local(host): + from core.llm_gateway import LLMGateway + + return LLMGateway._est_hote_local(host) + + +def _patch_communs(): + return ( + patch.object( + pipeline_service, "_fast_path_hote_muet", AsyncMock(return_value=False) + ), + patch.object(pipeline_service, "FAST_PATH_PROVIDERS", ["candidat"]), + patch.object( + pipeline_service, "_persist_fast_path_async", new_callable=AsyncMock + ), + ) + + +async def _drainer(gateway, session_id): + evenements = [] + p1, p2, p3 = _patch_communs() + with p1, p2, p3: + gen = pipeline_service.stream_discussion_fast_path_sse( + user_prompt="Quelle température dans le salon ?", + session_id=session_id, + gateway=gateway, + fast_path_cache={}, + inject_project_context=False, + ) + async for ligne in gen: + if ligne.startswith("data:"): + evenements.append(json.loads(ligne[5:].strip())) + return evenements + + +# ────────────────────────────────────────────────────────────────── +# Cycle de vie des outils en streaming (contrat ToolCallDisplay) +# ────────────────────────────────────────────────────────────────── + +class TestOutilsEnStreaming: + def test_appel_d_outil_produit_les_evenements_de_cycle_de_vie(self, monkeypatch): + """tool_call → tool_result (succès, durée) → le résultat de l'outil + atteint la réponse finale.""" + async def _faux_outil(nom, args, session_id=None): + return "21.5 °C" + + monkeypatch.setattr("core.vocal_tools.dispatch_vocal_tool", _faux_outil) + provider = OpenAICompatibleProvider([_APPEL_OUTIL, _REPONSE_FINALE]) + gateway = _GatewayFactice({"candidat": provider}) + + evenements = asyncio.run(_drainer(gateway, "test-outils-ok")) + types = [e["type"] for e in evenements] + + # Annonce AVANT exécution, résultat APRÈS, les deux avant la réponse. + assert types.index("tool_call") < types.index("tool_result") < types.index("done") + + appel = evenements[types.index("tool_call")] + assert appel["id"] == "call_1" + assert appel["toolName"] == "ha_get_state" + assert appel["args"] == {"entity_id": "sensor.salon_temperature"} + + resultat = evenements[types.index("tool_result")] + assert resultat["id"] == "call_1" + assert resultat["status"] == "success" + assert resultat["resultData"] == "21.5 °C" + assert resultat["errorMessage"] is None + assert resultat["executionTimeMs"] >= 0 + + # Le résultat de l'outil a bien atteint le tour de synthèse : le second + # appel LLM porte le message tool, et la réponse finale le synthétise. + messages_synthese = provider.appels[-1] + assert any( + m.get("role") == "tool" and m.get("content") == "21.5 °C" + for m in messages_synthese + ) + done = evenements[types.index("done")] + assert done["response"] == "Il fait 21,5 degrés dans le salon." + assert done["agents_used"] == ["discussion_chat"] + # La réponse outillée passe par token + sentence pour les clients TTS. + assert "token" in types and "sentence" in types + + def test_outil_en_erreur_produit_un_evenement_d_erreur_sans_flux_mort(self, monkeypatch): + """Un outil qui échoue → événement d'erreur, et la réponse reste + rendue (le flux ne meurt pas).""" + async def _outil_en_erreur(nom, args, session_id=None): + return "Erreur HA HTTP 500" + + monkeypatch.setattr("core.vocal_tools.dispatch_vocal_tool", _outil_en_erreur) + reponse_repli = {"role": "assistant", "content": "Le capteur ne répond pas."} + provider = OpenAICompatibleProvider([_APPEL_OUTIL, reponse_repli]) + gateway = _GatewayFactice({"candidat": provider}) + + evenements = asyncio.run(_drainer(gateway, "test-outils-ko")) + types = [e["type"] for e in evenements] + + resultat = evenements[types.index("tool_result")] + assert resultat["status"] == "error" + assert resultat["errorMessage"] == "Erreur HA HTTP 500" + assert resultat["resultData"] is None + + # Pas de flux mort : une réponse est rendue, pas d'événement error SSE. + assert "error" not in types + done = evenements[types.index("done")] + assert done["response"] == "Le capteur ne répond pas." + + def test_provider_sans_outils_ne_paie_aucun_aller_retour(self, monkeypatch): + """Latence : un provider qui ne supporte pas les outils garde le flux + historique — la boucle d'outils n'est JAMAIS invoquée.""" + boucle = AsyncMock(return_value="ne devrait jamais servir") + monkeypatch.setattr("core.vocal_tools.run_vocal_tool_loop", boucle) + gateway = _GatewayFactice({"candidat": _ProviderSansOutils()}) + + evenements = asyncio.run(_drainer(gateway, "test-sans-outils")) + + boucle.assert_not_awaited() + assert evenements == [ + {"type": "token", "text": "Bonjour."}, + {"type": "sentence", "text": "Bonjour."}, + { + "type": "done", + "response": "Bonjour.", + "agents_used": ["discussion_chat"], + }, + ] + + +# ────────────────────────────────────────────────────────────────── +# Annulation vocale : un appel d'outil en cours ne survit pas à l'abort +# ────────────────────────────────────────────────────────────────── + +def test_abort_pendant_la_boucle_d_outils(monkeypatch): + """Un abort vocal pendant un appel d'outil coupe le flux : événement + `aborted`, jamais de `done`.""" + from core.vocal_abort import abort_vocal_streams + + async def _outil_long(nom, args, session_id=None): + await asyncio.sleep(5) + return "21.5 °C" + + monkeypatch.setattr("core.vocal_tools.dispatch_vocal_tool", _outil_long) + + async def _scenario(): + provider = OpenAICompatibleProvider([_APPEL_OUTIL, _REPONSE_FINALE]) + gateway = _GatewayFactice({"candidat": provider}) + evenements = [] + p1, p2, p3 = _patch_communs() + with p1, p2, p3: + gen = pipeline_service.stream_discussion_fast_path_sse( + user_prompt="Quelle température dans le salon ?", + session_id="test-abort-outils", + gateway=gateway, + fast_path_cache={}, + inject_project_context=False, + ) + + async def _drainer(): + async for ligne in gen: + if ligne.startswith("data:"): + evenements.append(json.loads(ligne[5:].strip())) + + tache = asyncio.create_task(_drainer()) + # Attendre que l'appel d'outil soit annoncé, puis abort (barge-in). + for _ in range(400): + if any(e.get("type") == "tool_call" for e in evenements): + break + await asyncio.sleep(0.01) + abort_vocal_streams(session_id="test-abort-outils") + await asyncio.wait_for(tache, timeout=5) + return evenements + + evenements = asyncio.run(_scenario()) + types = [e["type"] for e in evenements] + + assert "tool_call" in types, "l'appel d'outil doit avoir été annoncé" + assert types[-1] == "aborted" + assert "done" not in types + + +# ────────────────────────────────────────────────────────────────── +# Chemin non streamé inchangé : run_vocal_tool_loop historique +# ────────────────────────────────────────────────────────────────── + +class TestBoucleOutilsNonStreamee: + """L'adaptation (on_event) ne doit rien changer à l'appelant historique + sans callback — les tests existants passent par ailleurs sans modification.""" + + @pytest.mark.asyncio + async def test_sans_callback_comportement_identique(self, monkeypatch): + monkeypatch.setattr( + "core.vocal_tools.provider_supports_openai_tools", lambda _p: True + ) + + async def _faux_outil(nom, args, session_id=None): + return "21.5 °C" + + monkeypatch.setattr("core.vocal_tools.dispatch_vocal_tool", _faux_outil) + provider = OpenAICompatibleProvider([_APPEL_OUTIL, _REPONSE_FINALE]) + + reponse = await run_vocal_tool_loop( + provider, + system_prompt="Tu es l'assistant vocal.", + user_prompt="Quelle température dans le salon ?", + session_id="test-non-streame", + ) + + assert reponse == "Il fait 21,5 degrés dans le salon." + + @pytest.mark.asyncio + async def test_avec_callback_evenements_emis_et_resultat_identique(self, monkeypatch): + monkeypatch.setattr( + "core.vocal_tools.provider_supports_openai_tools", lambda _p: True + ) + + async def _faux_outil(nom, args, session_id=None): + return "21.5 °C" + + monkeypatch.setattr("core.vocal_tools.dispatch_vocal_tool", _faux_outil) + provider = OpenAICompatibleProvider([_APPEL_OUTIL, _REPONSE_FINALE]) + recoit: list[dict] = [] + + async def _collecteur(evenement): + recoit.append(evenement) + + reponse = await run_vocal_tool_loop( + provider, + system_prompt="Tu es l'assistant vocal.", + user_prompt="Quelle température dans le salon ?", + session_id="test-avec-callback", + on_event=_collecteur, + ) + + assert reponse == "Il fait 21,5 degrés dans le salon." + assert [e["type"] for e in recoit] == ["tool_call", "tool_result"] + + @pytest.mark.asyncio + async def test_callback_defaillant_ne_casse_pas_la_boucle(self, monkeypatch): + """Une défaillance du callback d'événements ne doit JAMAIS casser la + boucle d'outils (le flux SSE est un observateur, pas un acteur).""" + monkeypatch.setattr( + "core.vocal_tools.provider_supports_openai_tools", lambda _p: True + ) + + async def _faux_outil(nom, args, session_id=None): + return "21.5 °C" + + monkeypatch.setattr("core.vocal_tools.dispatch_vocal_tool", _faux_outil) + provider = OpenAICompatibleProvider([_APPEL_OUTIL, _REPONSE_FINALE]) + + async def _collecteur_casse(_evenement): + raise RuntimeError("client SSE défaillant") + + reponse = await run_vocal_tool_loop( + provider, + system_prompt="Tu es l'assistant vocal.", + user_prompt="Quelle température dans le salon ?", + session_id="test-callback-casse", + on_event=_collecteur_casse, + ) + + assert reponse == "Il fait 21,5 degrés dans le salon." diff --git a/tests/unit/test_stream_discussion_raisonnement_t346.py b/tests/unit/test_stream_discussion_raisonnement_t346.py new file mode 100644 index 0000000..3593c7d --- /dev/null +++ b/tests/unit/test_stream_discussion_raisonnement_t346.py @@ -0,0 +1,235 @@ +"""test_stream_discussion_raisonnement_t346.py — #T346, canal de raisonnement. + +Le raisonnement des modèles qui en produisent (`deepseek-reasoner` et son +`delta.reasoning_content`, les compatibles à champ `reasoning`) était ignoré à +la lecture du chunk SSE : aucun canal de raisonnement n'existait dans le +moteur. Ce test couvre la traversée provider → gateway → pipeline → route SSE. + +Garde-fou central : le raisonnement ne doit JAMAIS partir au TTS ni se +retrouver dans la réponse rendue (ou mise en cache) — un bloc de pensée lu à +voix haute serait une régression visible immédiatement dans le salon. + +Aucun appel réseau réel ici : les providers sont des doubles, la sonde d'hôte +est mockée et `inject_project_context=False` évite tout appel RAG/BDD. +""" +import asyncio +import json +from unittest.mock import AsyncMock, MagicMock, patch + +from services import pipeline_service + +# ────────────────────────────────────────────────────────────────── +# Niveau provider : generate_stream sépare raisonnement et tokens +# ────────────────────────────────────────────────────────────────── + +def _reponse_sse(lignes: list[str]): + """Double de réponse HTTP streamée : iter_lines + statut 200.""" + reponse = MagicMock() + reponse.status_code = 200 + reponse.iter_lines.return_value = iter(lignes) + return reponse + + +def _chunk(delta: dict) -> str: + return "data: " + json.dumps({"choices": [{"delta": delta}]}) + + +class TestGenerateStreamCanalRaisonnement: + def _provider(self): + from core.openai_compat_provider import OpenAICompatibleProvider + + return OpenAICompatibleProvider( + provider_name="Test", base_url="http://unitaire.invalid/v1", + api_key="cle-factice", model="deepseek-reasoner", + ) + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_le_raisonnement_est_yielde_separement_des_tokens(self, mock_session): + """delta.reasoning_content → canal dédié, jamais confondu avec token.""" + mock_session.return_value.post.return_value = _reponse_sse([ + _chunk({"reasoning_content": "Je cher"}), + _chunk({"reasoning_content": "che."}), + _chunk({"content": "Bonjour"}), + _chunk({"content": " tout le monde."}), + "data: [DONE]", + ]) + + chunks = list(self._provider().generate_stream("s", "u")) + + raisonnements = [c for c in chunks if c.get("reasoning")] + tokens = [c for c in chunks if c.get("token")] + assert "".join(c["reasoning"] for c in raisonnements) == "Je cherche." + assert "".join(c["token"] for c in tokens) == "Bonjour tout le monde." + # Jamais les deux canaux dans un même chunk yieldé. + assert not any(c.get("reasoning") and c.get("token") for c in chunks) + # Le chunk de clôture garde exactement sa forme existante. + assert chunks[-1] == {"token": "", "done": True, "usage": None} + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_le_champ_reasoning_est_aussi_reconnu(self, mock_session): + """Variante `delta.reasoning` (autres compatibles OpenAI).""" + mock_session.return_value.post.return_value = _reponse_sse([ + _chunk({"reasoning": "Réfléchissons."}), + _chunk({"content": "Fait."}), + "data: [DONE]", + ]) + + chunks = list(self._provider().generate_stream("s", "u")) + + assert chunks[0] == {"reasoning": "Réfléchissons.", "done": False, "usage": None} + assert chunks[1] == {"token": "Fait.", "done": False, "usage": None} + + @patch("core.openai_compat_provider.SharedHTTPPool.get_session") + def test_sans_raisonnement_le_flux_est_inchange(self, mock_session): + """Un provider qui ne raisonne pas yield exactement ce qu'avant.""" + mock_session.return_value.post.return_value = _reponse_sse([ + _chunk({"content": "Salut"}), + "data: [DONE]", + ]) + + chunks = list(self._provider().generate_stream("s", "u")) + + assert chunks == [ + {"token": "Salut", "done": False, "usage": None}, + {"token": "", "done": True, "usage": None}, + ] + + +def test_le_wrapper_laisse_passer_le_canal_raisonnement(): + """ClaudeInstructionsWrapper (yield from) ne doit pas fondre les canaux.""" + from core.llm.providers.deepseek import ClaudeInstructionsWrapper + + class _ProviderRaisonneur: + def generate_stream(self, system_prompt, user_prompt, **kwargs): + yield {"reasoning": "hm", "done": False, "usage": None} + yield {"token": "ok", "done": False, "usage": None} + yield {"token": "", "done": True, "usage": None} + + chunks = list(ClaudeInstructionsWrapper(_ProviderRaisonneur()).generate_stream("s", "u")) + + assert chunks[0] == {"reasoning": "hm", "done": False, "usage": None} + assert chunks[1] == {"token": "ok", "done": False, "usage": None} + + +# ────────────────────────────────────────────────────────────────── +# Niveau SSE : événements thinking distincts, TTS jamais contaminé +# ────────────────────────────────────────────────────────────────── + +class _ProviderRaisonneur: + """Provider factice : raisonne d'abord, répond ensuite.""" + + def __init__(self): + self.base_url = "https://api.deepseek.com/chat/completions" + + def generate_stream(self, system_prompt, user_prompt, **kwargs): + yield {"reasoning": "Analysons la demande", "done": False, "usage": None} + yield {"reasoning": " : c'est une salutation.", "done": False, "usage": None} + yield {"token": "Bonjour", "done": False, "usage": None} + yield {"token": " tout le monde.", "done": False, "usage": None} + yield {"token": "", "done": True, "usage": None} + + +class _ProviderSansRaisonnement: + """Provider factice : aucun champ raisonnement, flux historique.""" + + def __init__(self): + self.base_url = "https://api.deepseek.com/chat/completions" + + def generate_stream(self, system_prompt, user_prompt, **kwargs): + yield {"token": "Bonjour.", "done": False, "usage": None} + yield {"token": "", "done": True, "usage": None} + + +class _GatewayFactice: + def __init__(self, providers): + self._providers = providers + + def get_provider(self, name): + if name not in self._providers: + raise ValueError(f"Provider LLM inconnu : {name}") + return self._providers[name] + + @staticmethod + def _est_hote_local(host): + from core.llm_gateway import LLMGateway + + return LLMGateway._est_hote_local(host) + + +async def _drainer(gateway, provider_name, cache): + """Draine le générateur SSE et renvoie la liste des événements parsés.""" + evenements = [] + with ( + patch.object( + pipeline_service, "_fast_path_hote_muet", AsyncMock(return_value=False) + ), + patch.object(pipeline_service, "FAST_PATH_PROVIDERS", [provider_name]), + patch.object( + pipeline_service, "_persist_fast_path_async", new_callable=AsyncMock + ), + ): + gen = pipeline_service.stream_discussion_fast_path_sse( + user_prompt="bonjour", + session_id="test-raisonnement", + gateway=gateway, + fast_path_cache=cache, + inject_project_context=False, + ) + async for ligne in gen: + if ligne.startswith("data:"): + evenements.append(json.loads(ligne[5:].strip())) + return evenements + + +class TestSseCanalRaisonnement: + def test_le_raisonnement_part_dans_des_evenements_distincts(self): + """LE test central : raisonnement et texte dans des événements séparés, + et le texte final (TTS) ne contient pas une ligne de raisonnement.""" + cache: dict = {} + gateway = _GatewayFactice({"raisonneur": _ProviderRaisonneur()}) + + evenements = asyncio.run(_drainer(gateway, "raisonneur", cache)) + types = [e["type"] for e in evenements] + + # Les fragments de pensée arrivent sur le canal thinking, dans l'ordre. + pensee = "".join(e["text"] for e in evenements if e["type"] == "thinking") + assert pensee == "Analysons la demande : c'est une salutation." + + # thinking_done porte la durée et précède le premier token. + assert "thinking_done" in types + assert evenements[types.index("thinking_done")]["durationMs"] >= 0 + assert types.index("thinking_done") < types.index("token") + + # GARDE-FOU : aucun fragment de raisonnement dans ce qui est rendu, + # parlé (sentence) ou mis en cache. + rendu = "".join( + e.get("text", "") + e.get("response", "") + for e in evenements + if e["type"] in ("token", "sentence", "done") + ) + assert "Analysons" not in rendu + assert "salutation" not in rendu + assert all("Analysons" not in v for v in cache.values()) + + # La réponse reste intacte pour le TTS. + done = evenements[types.index("done")] + assert done["response"] == "Bonjour tout le monde." + assert done["agents_used"] == ["discussion_chat"] + + def test_sans_raisonnement_le_flux_est_identique_evenement_par_evenement(self): + """Un provider qui ne raisonne pas produit exactement le flux d'avant : + un client qui ignore les nouveaux événements ne voit aucun changement.""" + cache: dict = {} + gateway = _GatewayFactice({"muet": _ProviderSansRaisonnement()}) + + evenements = asyncio.run(_drainer(gateway, "muet", cache)) + + assert evenements == [ + {"type": "token", "text": "Bonjour."}, + {"type": "sentence", "text": "Bonjour."}, + { + "type": "done", + "response": "Bonjour.", + "agents_used": ["discussion_chat"], + }, + ] diff --git a/tests/unit/test_stream_discussion_sonde.py b/tests/unit/test_stream_discussion_sonde.py new file mode 100644 index 0000000..2d1b370 --- /dev/null +++ b/tests/unit/test_stream_discussion_sonde.py @@ -0,0 +1,163 @@ +"""test_stream_discussion_sonde.py — #T352, sonde d'hôte sur le chemin streamé. + +Le correctif porte sur `stream_discussion_fast_path_sse`, le générateur SSE qui +sert `/api/execute/stream` en mode chat — soit 166 des 169 échanges vocaux des 30 +derniers jours (98 % du trafic). La sonde `_fast_path_hote_muet` (livrée par +#T352) ne couvrait que le chemin bufferisé `run_fast_path` ; ce test vérifie +qu'elle est désormais appliquée à la cascade streamée avec les MÊMES garde-fous. + +Aucun appel réseau réel ici : la sonde (`_fast_path_hote_muet`) est mockée et +`inject_project_context=False` évite tout appel RAG/BDD. +""" +import asyncio +import json +from unittest.mock import AsyncMock, patch + +from services import pipeline_service + + +class _ProviderStream: + """Provider factice : base_url + generate_stream compteur d'appels.""" + + def __init__(self, base_url, reponse): + self.base_url = base_url + self.reponse = reponse + self.appels = 0 + + def generate_stream(self, system_prompt, user_prompt, **kwargs): + self.appels += 1 + yield {"token": self.reponse, "done": False} + yield {"token": "", "done": True} + + +class _GatewayStream: + """Gateway factice : get_provider + vrai critère _est_hote_local.""" + + def __init__(self, providers): + self._providers = providers + + def get_provider(self, name): + if name not in self._providers: + raise ValueError(f"Provider LLM inconnu : {name}") + return self._providers[name] + + @staticmethod + def _est_hote_local(host): + from core.llm_gateway import LLMGateway + return LLMGateway._est_hote_local(host) + + +def _gateway_avec(providers): + return _GatewayStream(providers) + + +async def _collect(gateway, noms_providers, mock_sonde): + """Draine le générateur et renvoie la liste des lignes SSE.""" + lignes = [] + with ( + patch.object(pipeline_service, "_fast_path_hote_muet", mock_sonde), + patch.object(pipeline_service, "FAST_PATH_PROVIDERS", noms_providers), + patch.object(pipeline_service, "_persist_fast_path_async", new_callable=AsyncMock), + ): + gen = pipeline_service.stream_discussion_fast_path_sse( + user_prompt="bonjour", + session_id="test-session", + gateway=gateway, + fast_path_cache={}, + inject_project_context=False, + ) + async for ligne in gen: + lignes.append(ligne) + return lignes + + +def _texte_done(lignes): + """Extrait le champ 'response' du dernier événement de type 'done'.""" + reponse = None + for ligne in lignes: + if ligne.startswith("data:"): + evt = json.loads(ligne[5:].strip()) + if evt.get("type") == "done": + reponse = evt.get("response") + return reponse + + +class TestStreamDiscussionSonde: + def test_hote_muet_saute_et_le_suivant_repond(self): + """Cascade [ollama_pc, autre] : hôte muet → ollama_pc jamais streamé, + la réponse vient de `autre`, et la sonde a bien été consultée.""" + local_muet = _ProviderStream("http://192.168.1.10:11434/v1", "MUET") + autre = _ProviderStream("http://192.168.1.10:11434/v1", "AUTRE") + gateway = _gateway_avec({"ollama_pc": local_muet, "autre": autre}) + mock_sonde = AsyncMock(side_effect=[True]) + + lignes = asyncio.run(_collect(gateway, ["ollama_pc", "autre"], mock_sonde)) + + assert _texte_done(lignes) == "AUTRE" + assert local_muet.appels == 0, "le provider muet ne doit jamais être streamé" + assert autre.appels == 1 + mock_sonde.assert_awaited_once() + + def test_tous_muets_le_dernier_est_quand_meme_streamed(self): + """Tous les candidats muets → la cascade n'est pas vidée : le dernier + est tenté quand même (mieux vaut payer 2 s que ne rien répondre).""" + a = _ProviderStream("http://192.168.1.10:11434/v1", "A") + b = _ProviderStream("http://192.168.1.10:11434/v1", "B") + gateway = _gateway_avec({"a": a, "b": b}) + mock_sonde = AsyncMock(side_effect=[True]) + + lignes = asyncio.run(_collect(gateway, ["a", "b"], mock_sonde)) + + assert _texte_done(lignes) == "B" + assert a.appels == 0 + assert b.appels == 1 + assert mock_sonde.await_count == 1, "le dernier candidat ne doit pas être sondé" + + def test_hote_qui_decroche_est_streamed(self): + """Hôte qui décroche → le provider est streamé normalement (sonde False).""" + ok = _ProviderStream("http://192.168.1.10:11434/v1", "OK") + autre = _ProviderStream("http://192.168.1.10:11434/v1", "AUTRE") + gateway = _gateway_avec({"ollama_pc": ok, "autre": autre}) + mock_sonde = AsyncMock(side_effect=[False]) + + lignes = asyncio.run(_collect(gateway, ["ollama_pc", "autre"], mock_sonde)) + + assert _texte_done(lignes) == "OK" + assert ok.appels == 1 + assert autre.appels == 0 + mock_sonde.assert_awaited_once() + + def test_provider_cloud_muet_tcp_n_est_pas_ecarte(self): + """Un provider cloud muet au niveau TCP n'est pas écarté : la sonde ne + le concerne pas (elle ne s'applique qu'aux hôtes locaux).""" + cloud = _ProviderStream("https://api.deepseek.com/v1/chat/completions", "CLOUD") + autre = _ProviderStream("http://192.168.1.10:11434/v1", "AUTRE") + gateway = _gateway_avec({"deepseek-chat": cloud, "autre": autre}) + # La sonde mockée renvoie False (le filtre ne juge pas le cloud muet). + mock_sonde = AsyncMock(side_effect=[False]) + + lignes = asyncio.run(_collect(gateway, ["deepseek-chat", "autre"], mock_sonde)) + + assert _texte_done(lignes) == "CLOUD" + assert cloud.appels == 1 + assert autre.appels == 0 + mock_sonde.assert_awaited_once() + + def test_sonde_qui_leve_le_provider_est_tente(self): + """Une sonde qui lève → « on ne sait pas » → le provider est tenté comme + avant (le doute ne devient pas un refus). + + `_fast_path_hote_muet` attrape toute exception interne (socket, DNS) et + renvoie False ; on simule donc son résultat réel après une levée de sonde. + """ + ok = _ProviderStream("http://192.168.1.10:11434/v1", "OK") + autre = _ProviderStream("http://192.168.1.10:11434/v1", "AUTRE") + gateway = _gateway_avec({"ollama_pc": ok, "autre": autre}) + mock_sonde = AsyncMock(side_effect=[False]) + + lignes = asyncio.run(_collect(gateway, ["ollama_pc", "autre"], mock_sonde)) + + assert _texte_done(lignes) == "OK" + assert ok.appels == 1 + assert autre.appels == 0 + mock_sonde.assert_awaited_once() diff --git a/tests/unit/test_t169_gateway_verdicts.py b/tests/unit/test_t169_gateway_verdicts.py index 73f0eee..a61d041 100644 --- a/tests/unit/test_t169_gateway_verdicts.py +++ b/tests/unit/test_t169_gateway_verdicts.py @@ -8,22 +8,49 @@ SYMPTÔME A — « zai-glm-4.7 routé vers Cerebras » Verdict établi : comportement CONFORME. `zai-glm-4.7` est un modèle Cerebras par design — descripteur CerebrasPlugin (core/llm/builtin_providers.py:1249), câblage -gateway (core/llm_gateway.py:267), seed (seed_models_db.py:293), usage documenté -(agents/dreamer_agent.py:528-531 : « escaladé vers zai-glm-4.7 — GLM/Z.ai hébergé +gateway (core/llm_gateway.py:321), seed (seed_models_db.py:294), usage documenté +(agents/dreamer_agent.py:530-532 : « escaladé vers zai-glm-4.7 — GLM/Z.ai hébergé gratuitement via la clé Cerebras — décision Axel 07/07 »). `glm-4.7` est un modèle -Zhipu DISTINCT (ZhipuPlugin builtin_providers.py:1774, gateway:358, seed:316) ; -`dashscope/glm-4.7` un troisième (préfixé, gateway:385, seed:332). La résolution se -fait par clé exacte (`self.providers.get(name.lower())`, llm_gateway.py:661) : ni -préfixe rogné, ni correspondance partielle, ni dernier gagnant. Aucune collision +Zhipu DISTINCT (ZhipuPlugin builtin_providers.py:1758, gateway:414, seed:318) ; +`dashscope/glm-4.7` un troisième (préfixé, catalogue conservé, D-8 : plus instancié, +gateway:437). La résolution se fait par clé exacte (`self.providers.get(name.lower())`) : +ni préfixe rogné, ni correspondance partielle, ni dernier gagnant. Aucune collision d'identifiants entre plugins ni entre plugin et seed. +Réfutation de l'hypothèse « clé absente → repli muet » : si les deux clés Cerebras +sont absentes, `key_paid_path` est vide (core/llm_gateway.py:310-311) → `zai-glm-4.7` +n'est PAS construit (le `if key_paid_path:` de la ligne 317 ne s'exécute pas). Un +accès explicite lève alors une ValueError bruyante (« Provider LLM inconnu », +core/llm_gateway.py:719), jamais une redirection silencieuse vers un autre provider. +Sur la voie plugin (core/llm/provider_registration.py:102-108), `CredentialsManquantes` +écarte proprement le provider (log info + `rapport.ecartes`), sans repli. + SYMPTÔME B — « timeout 120 s » -Verdict établi : comportement CONFORME. Le read de 120 s est le DÉFAUT de la famille -`openai_compat` (core/llm_timeouts.py:24), pas un héritage du réglage local -`lmstudio` ((2, 120), llm_timeouts.py:25, utilisé uniquement par LMStudioProvider, -core/llm/providers/deepseek.py:206). Le seul timeout court est `ollama_pc` (2, 15), -explicite (core/llm_gateway.py:430-436). Aucun provider distant n'utilise la famille -`lmstudio`. +Verdict établi : comportement CONFORME (documenté, par design — pas un défaut). Le +read de 120 s est le DÉFAUT de la famille `openai_compat` (core/llm_timeouts.py:24), +pas un héritage du réglage local `lmstudio` ((2, 120), llm_timeouts.py:25, utilisé +uniquement par LMStudioProvider, core/llm/providers/deepseek.py:185). Le seul +timeout court est `ollama_pc` (2, 15), explicite (core/llm_gateway.py:492). Aucun +provider distant n'utilise la famille `lmstudio`. Sur le chemin interactif, la +fenêtre de session vaut aussi 120 s (core/fenetres_execution.py:114) : un appel +amont qui pend bloque donc jusqu'à 120 s — arbitrage produit ASSUMÉ et documenté +(core/fenetres_execution.py:103-112 : « c'est un arbitrage produit assumé... pas un +oubli »). Le raccourcir est une décision produit, pas une correction de bug. + +TABLE DES TIMEOUTS PAR FAMILLE (source de vérité : core/llm_timeouts.py) : + +| Famille | connect | read | Source fichier:ligne | Providers concernés | +|--------------------|---------|--------|-----------------------------------------|---------------------| +| gemini | 5.0 | 120.0 | llm_timeouts.py:23 | GeminiProvider, GeminiNativeProvider | +| openai_compat | 5.0 | 120.0 | llm_timeouts.py:24 | deepseek, mistral, cohere, cerebras, openrouter, deepinfra, xai, minimax, zhipu, dashscope, ollama_local | +| lmstudio | 2.0 | 120.0 | llm_timeouts.py:25 | LMStudioProvider (local) | +| anthropic | 5.0 | 180.0 | llm_timeouts.py:26 | AnthropicNativeProvider | +| claude_cli | — | 240.0 | llm_timeouts.py:27 | ClaudeCLIProvider | +| ollama_pc (explicite) | 2.0 | 15.0 | llm_gateway.py:492 | ollama_pc seul (LAN) | +| deck_ollama | 2.0 | 60.0 | core/llm/providers/deepseek.py:276-277 | OllamaDeckProvider (endpoint local Deck) | + +Surcharge possible via `config.json` (`provider_timeouts`), lue par +`get_timeout` (core/llm_timeouts.py:47-50). """ @@ -76,13 +103,11 @@ def test_resolution_par_cle_exacte_des_trois_glm(gateway_cles_factices): ) assert glm.model == "glm-4.7" - dash = providers["dashscope/glm-4.7"] - assert "dashscope.aliyuncs.com" in dash.base_url, ( - "dashscope/glm-4.7 doit être câblé chez DashScope — reçu : {dash.base_url}" - ) + # D-8 : dashscope/* n'est plus instancié même avec une clé factice. + assert "dashscope/glm-4.7" not in providers - # Les trois objets sont distincts : aucune collision d'entrée de registre. - assert zai is not glm and glm is not dash and zai is not dash + # Les deux objets restants sont distincts : aucune collision d'entrée de registre. + assert zai is not glm def test_aucun_id_modele_partage_entre_plugins(): diff --git a/tests/unit/test_t347_cache_hit_cout.py b/tests/unit/test_t347_cache_hit_cout.py new file mode 100644 index 0000000..f490aea --- /dev/null +++ b/tests/unit/test_t347_cache_hit_cout.py @@ -0,0 +1,220 @@ +""" +tests/unit/test_t347_cache_hit_cout.py — Les tokens cache-hit DeepSeek sont facturés au bon tarif (#T347). + +Avant cette PR, la chaîne de comptage jetait les compteurs de cache renvoyés par +l'API : `record_usage()` facturait TOUT le prompt au tarif d'entrée plein, et le +barème (`pricing.py`) ignorait `input_cache_hit_cost_per_m`. Résultat : l'unique +indicateur de dépense du moteur surestimait le coût d'un facteur qui se compte en +unités (97,5 % de cache-hit mesuré sur un appel réel le 17/08). + +Sonde réelle (api.deepseek.com, second appel identique, HTTP 200) : + {"prompt_tokens": 3413, "completion_tokens": 1, "total_tokens": 3414, + "prompt_tokens_details": {"cached_tokens": 3328}, + "prompt_cache_hit_tokens": 3328, "prompt_cache_miss_tokens": 85} + +Ces tests écrivent dans une base créée par le VRAI schéma (`override_db_path` + +`tmp_path`, motif de #T299) et n'appellent AUCUN LLM réel (garde-fou #T261). +""" +import sqlite3 + +import pytest + +# Sonde réelle DeepSeek (second appel, cache-hit massif). +SONDE_CACHE = { + "prompt_tokens": 3413, + "completion_tokens": 1, + "total_tokens": 3414, + "prompt_tokens_details": {"cached_tokens": 3328}, + "prompt_cache_hit_tokens": 3328, + "prompt_cache_miss_tokens": 85, +} +# Objet `usage` OpenAI ordinaire : aucun champ de cache. +USAGE_OPENAI_ORDINAIRE = {"prompt_tokens": 100, "completion_tokens": 50} + +# Tarifs deepseek-chat (pricing_strategy.json) en $/token. +_INPUT = 0.14 / 1_000_000 +_OUTPUT = 0.28 / 1_000_000 +_CACHE = 0.003 / 1_000_000 + + +@pytest.fixture +def base_temporaire(tmp_path): + """Base neuve au schéma canonique, isolée de la base de développement.""" + from core import runtime_db + + db = tmp_path / "t347.db" + runtime_db.override_db_path(str(db)) + runtime_db.get_connection().close() # déclenche _init_schema (schéma réel) + return str(db) + + +@pytest.fixture +def hors_pic(monkeypatch): + """Neutralise le doublement en heures de pic DeepSeek (CI indépendante de l'heure).""" + import core.pricing as pricing + + monkeypatch.setattr(pricing, "is_deepseek_peak_hours", lambda now: False) + + +def _ligne_usage(db: str) -> dict: + """Retourne la (seule) ligne token_usage sous forme de dict.""" + conn = sqlite3.connect(db) + try: + row = conn.execute( + "SELECT model, prompt_tokens, completion_tokens, cost_usd, cache_hit_tokens " + "FROM token_usage" + ).fetchone() + finally: + conn.close() + assert row is not None, "aucune ligne enregistrée dans token_usage" + cols = ("model", "prompt_tokens", "completion_tokens", "cost_usd", "cache_hit_tokens") + return dict(zip(cols, row, strict=True)) + + +class TestRecordUsageTarifCache: + """Le test central : le compteur cache change le coût, PAS le calcul de master.""" + + def test_cache_hit_facture_au_tarif_cache(self, base_temporaire, hors_pic): + """90 000 tokens cache-hit sur 100 000 → coût = 10k*input + 90k*cache + 1k*output.""" + from core.token_tracker import record_usage + + record_usage( + "deepseek-chat", 100_000, 1_000, + cache_hit_tokens=90_000, session_id="s_t347_central", + ) + + ligne = _ligne_usage(base_temporaire) + attendu = 10_000 * _INPUT + 90_000 * _CACHE + 1_000 * _OUTPUT + # PAS 100_000 * _INPUT + 1_000 * _OUTPUT (= 0.01428) : c'est le coût de master. + assert ligne["cost_usd"] == pytest.approx(attendu, rel=1e-9), ligne + assert ligne["cache_hit_tokens"] == 90_000 + + def test_sans_compteur_cache_coût_identique_a_master(self, base_temporaire, hors_pic): + """Le même appel SANS compteur cache coûte exactement ce que coûtait master.""" + from core.token_tracker import record_usage + + record_usage("deepseek-chat", 100_000, 1_000, session_id="s_t347_jumeau") + + ligne = _ligne_usage(base_temporaire) + # Coût d'avant la PR : tout le prompt au tarif plein. + attendu_master = 100_000 * _INPUT + 1_000 * _OUTPUT + assert ligne["cost_usd"] == pytest.approx(attendu_master, rel=1e-9), ligne + assert ligne["cache_hit_tokens"] == 0 + + def test_cache_hit_borne_a_prompt_tokens(self, base_temporaire, hors_pic): + """Un compteur cache > prompt ne peut pas produire un coût négatif.""" + from core.token_tracker import record_usage + + record_usage( + "deepseek-chat", 100, 10, + cache_hit_tokens=500, session_id="s_t347_borne", + ) + + ligne = _ligne_usage(base_temporaire) + attendu = 0 * _INPUT + 100 * _CACHE + 10 * _OUTPUT # cache borné à 100 + assert ligne["cost_usd"] == pytest.approx(attendu, rel=1e-9), ligne + + +class TestChaineProvider: + """La vraie chaîne : OpenAICompatibleProvider._record_usage transmet le compteur.""" + + def _provider(self) -> "object": + from core.openai_compat_provider import OpenAICompatibleProvider + + return OpenAICompatibleProvider( + provider_name="T347", + base_url="http://provider-factice.invalid", + api_key="cle-de-test", + model="deepseek-chat", + ) + + def test_sonde_deepseek_enregistre_3328_tokens_cache(self, base_temporaire, hors_pic): + """L'objet `usage` de la sonde réelle → 3328 tokens cache-hit enregistrés.""" + provider = self._provider() + provider._record_usage(SONDE_CACHE, session_id="s_t347_sonde") + + ligne = _ligne_usage(base_temporaire) + assert ligne["cache_hit_tokens"] == 3328 + assert ligne["prompt_tokens"] == 3413 + assert ligne["completion_tokens"] == 1 + + def test_usage_openai_ordinaire_enregistre_zero_cache(self, base_temporaire, hors_pic): + """Un `usage` OpenAI sans champ cache → 0 token cache (on n'invente rien).""" + provider = self._provider() + provider._record_usage(USAGE_OPENAI_ORDINAIRE, session_id="s_t347_ordinaire") + + ligne = _ligne_usage(base_temporaire) + assert ligne["cache_hit_tokens"] == 0 + assert ligne["cost_usd"] == pytest.approx( + 100 * _INPUT + 50 * _OUTPUT, rel=1e-9 + ) + + def test_repli_sur_prompt_tokens_details_cached_tokens(self, base_temporaire, hors_pic): + """Forme normalisée OpenAI (`prompt_tokens_details.cached_tokens`) → lue.""" + provider = self._provider() + usage_openai_cache = { + "prompt_tokens": 200, + "completion_tokens": 5, + "prompt_tokens_details": {"cached_tokens": 150}, + } + provider._record_usage(usage_openai_cache, session_id="s_t347_details") + + ligne = _ligne_usage(base_temporaire) + assert ligne["cache_hit_tokens"] == 150 + + +class TestMigrationIdempotente: + """La colonne cache_hit_tokens s'ajoute aux bases préexistantes sans perte.""" + + def test_migration_ajoute_la_colonne_sans_perdre_de_ligne(self, tmp_path): + """Base à l'ancien schéma (sans cache_hit_tokens) → colonne ajoutée, ligne intacte.""" + from core import runtime_db + + db = tmp_path / "ancienne.db" + runtime_db.override_db_path(str(db)) + + # 1. Base préexistante à l'ANCIEN schéma (avant #T347), avec une ligne utile. + conn = sqlite3.connect(db) + conn.execute(""" + CREATE TABLE token_usage ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT, + timestamp REAL NOT NULL, + model TEXT NOT NULL, + prompt_tokens INTEGER DEFAULT 0, + completion_tokens INTEGER DEFAULT 0, + total_tokens INTEGER DEFAULT 0, + cost_usd REAL DEFAULT 0.0, + channel TEXT, + agent_name TEXT + ) + """) + conn.execute( + "INSERT INTO token_usage (session_id, timestamp, model, prompt_tokens, " + "completion_tokens, total_tokens, cost_usd) VALUES (?, ?, ?, ?, ?, ?, ?)", + ("s_ancienne", 1234.0, "deepseek-chat", 100, 50, 150, 0.01428), + ) + conn.commit() + conn.close() + + # 2. Premier passage : get_connection() déclenche _init_schema → migration. + runtime_db.get_connection().close() + + conn = sqlite3.connect(db) + cols = {r[1] for r in conn.execute("PRAGMA table_info(token_usage)")} + assert "cache_hit_tokens" in cols, "colonne absente après migration" + ligne = conn.execute( + "SELECT session_id, model, prompt_tokens, cost_usd FROM token_usage" + ).fetchone() + conn.close() + # La ligne préexistante n'a pas été perdue ni modifiée. + assert ligne == ("s_ancienne", "deepseek-chat", 100, 0.01428), ligne + + # 3. Second passage : idempotent, ne casse rien. + runtime_db.get_connection().close() + conn = sqlite3.connect(db) + cols2 = {r[1] for r in conn.execute("PRAGMA table_info(token_usage)")} + nb = conn.execute("SELECT COUNT(*) FROM token_usage").fetchone()[0] + conn.close() + assert cols2 == cols + assert nb == 1 diff --git a/tests/unit/test_temperature_lieu_t371.py b/tests/unit/test_temperature_lieu_t371.py new file mode 100644 index 0000000..090cee8 --- /dev/null +++ b/tests/unit/test_temperature_lieu_t371.py @@ -0,0 +1,89 @@ +""" +[#T371] La température ne doit jamais venir d'une autre pièce que celle citée. + +Trois défauts mesurés sur la prod du 18/08, tous « indiscernables d'une bonne +réponse » puisque la valeur servie était exacte : + + a) « quelle est la température de la buanderie ? » → capteur du SALON ; + b) « quelle est la température extérieure ? » → capteur du SALON, alors + que l'étage météo (#T367) sait répondre et n'était jamais atteint ; + c) « quelle est la température de la chambre ? » → plus RIEN depuis #T364, + alors que `sensor.bedroom_temperature` est vivant (24,49 °C + relevé sur le HA de prod le 18/08). + +Ces tests portent sur la DÉTECTION (`match_*`), donc sans aucun appel réseau. +""" + +from services.ha_state_query import ( + _PIECES_CONNUES, + _TEMPERATURE_SOURCES, + match_ha_state_query, +) +from services.ha_weather_query import match_weather_query + + +class TestPieceInconnue: + """(a) Un lieu nommé mais inconnu ne reçoit jamais le capteur du voisin.""" + + def test_la_buanderie_ne_recoit_pas_la_temperature_du_salon(self): + assert match_ha_state_query("quelle est la temperature de la buanderie ?") is None + + def test_meme_accentuee(self): + # `normalize_ha_command_prompt` dépouille les accents ici — contrairement + # à `core/vocal_host.py` (#T383). Le test l'atteste dans les deux formes. + assert match_ha_state_query("quelle est la température de la buanderie ?") is None + + def test_sans_lieu_nomme_le_salon_reste_le_defaut(self): + # « il fait combien ? » n'exprime aucun lieu : le repli reste légitime. + q = match_ha_state_query("quelle est la temperature ?") + assert q is not None and q.display.endswith("|salon") + + def test_de_degres_n_est_pas_un_lieu(self): + # Garde-fou : la préposition « de » attrape « degres », qui n'est pas un + # lieu. Sans l'exclusion, cette phrase perdrait sa réponse. + q = match_ha_state_query("il fait combien de degres ?") + assert q is not None and q.display.endswith("|salon") + + def test_la_maison_vaut_la_piece_de_vie(self): + # Choix assumé : « la maison », pour le moteur, c'est le salon. + q = match_ha_state_query("quelle est la temperature de la maison ?") + assert q is not None and q.display.endswith("|salon") + + +class TestExterieur: + """(b) L'extérieur appartient à la météo, pas à l'état domotique.""" + + def test_l_etat_relache_la_temperature_exterieure(self): + assert match_ha_state_query("quelle est la temperature exterieure ?") is None + assert match_ha_state_query("quelle est la température extérieure ?") is None + + def test_et_la_meteo_la_reconnait(self): + # Relâcher ne suffit pas : sans marqueur météo, la question tomberait au + # chat. Les deux orthographes sont vérifiées car `ha_weather_query` + # CONSERVE les accents (cause racine de #T383). + assert match_weather_query("quelle est la temperature exterieure ?") is not None + assert match_weather_query("quelle est la température extérieure ?") is not None + + def test_une_piece_nommee_reste_a_l_etat(self): + # La météo ne doit pas voler les questions intérieures. + assert match_weather_query("il fait combien dans le salon ?") is None + + +class TestChambre: + """(c) La chambre a un capteur : elle doit répondre, et avec le sien.""" + + def test_la_chambre_est_lue_sur_son_propre_capteur(self): + q = match_ha_state_query("quelle est la temperature de la chambre ?") + assert q is not None, "#T364 avait supprimé la réponse au lieu de la corriger" + assert q.entity_id == "sensor.bedroom_temperature" + assert q.display.endswith("|chambre") + + def test_le_salon_n_a_pas_bouge(self): + q = match_ha_state_query("quelle est la temperature du salon ?") + assert q is not None + assert q.entity_id == "climate.living_room" + + def test_toute_source_declaree_est_une_piece_connue(self): + # Cause racine figée : une pièce qui gagne un capteur doit du même coup + # devenir « connue », sinon le repli salon la rattrape en silence. + assert set(_TEMPERATURE_SOURCES) <= set(_PIECES_CONNUES) diff --git a/tests/unit/test_version_info.py b/tests/unit/test_version_info.py new file mode 100644 index 0000000..f1f4319 --- /dev/null +++ b/tests/unit/test_version_info.py @@ -0,0 +1,170 @@ +"""Tests de `core.version_info` et de son exposition (#T355). + +Couvre les trois exigences mécaniques : +1. LE TEST CENTRAL — quand la résolution du commit échoue (git absent, dépôt + absent, commande en erreur), la réponse porte une valeur EXPLICITEMENT + indéterminée (`None`), jamais une chaîne qui ressemble à une version. +2. LE TEST JUMEAU — la résolution n'est effectuée qu'une seule fois : deux appels + consécutifs ne lancent qu'un seul sous-processus. +3. `/healthz` continue de répondre 200 sans authentification et n'ajoute aucun + champ sensible (au plus le SHA court). + +Aucun de ces tests ne lance de vraie commande `git` : `subprocess.run` est mocké. +""" + +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import core.version_info as version_info +from api.routes.health import router as health_router + + +@pytest.fixture(autouse=True) +def _reset_cache(): + """Réinitialise le cache module entre chaque test (indépendance).""" + with patch.object(version_info, "_resolved", False), patch.object(version_info, "_cached_info", None): + yield + + +def _run_git_failure(exception): + """Mock de subprocess.run qui lève une exception (git absent, timeout…).""" + def _fake(args, **kwargs): # noqa: ANN001, ANN202 + raise exception + return _fake + + +def _run_git_nonzero(): + """Mock de subprocess.run qui simule une commande git en erreur (rc != 0).""" + class _Proc: + returncode = 128 + stdout = "fatal: not a git repository" + return lambda args, **kwargs: _Proc() # noqa: ANN001, ANN202 + + +# ── 1. LE TEST CENTRAL : échec ⇒ indéterminé, jamais une valeur valide ───────── + +@pytest.mark.parametrize("fake_run", [ + _run_git_failure(FileNotFoundError("git absent")), # git non installé + _run_git_failure(TimeoutError("commande en timeout")), # commande en erreur + _run_git_nonzero(), # dépôt absent / commande en erreur (rc != 0) +]) +def test_echec_commit_jamais_valeur_valide(fake_run): + """Quand le commit est indéterminable, la réponse dit None — jamais une chaîne.""" + with patch("core.version_info.subprocess.run", fake_run): + info = version_info.get_version_info() + + # Valeur EXPLICITEMENT indéterminée (assertion sur la valeur exacte). + assert info["commit"] is None + assert info["commit_date"] is None + # Jamais une chaîne d'apparence valide. + assert not isinstance(info["commit"], str) + assert not isinstance(info["commit_date"], str) + # Un motif lisible est fourni. + assert isinstance(info["reason"], str) and info["reason"] + + +# ── 2. LE TEST JUMEAU : une seule résolution / un seul sous-processus ───────── + +def test_resolution_unique_entre_deux_appels(): + """Deux appels consécutifs ne lancent qu'un seul sous-processus git.""" + class _Proc: + returncode = 0 + stdout = "abcdef1" + calls = [] + + def _fake(args, **kwargs): # noqa: ANN001, ANN202 + calls.append(tuple(args)) + return _Proc() + + with patch("core.version_info.subprocess.run", _fake): + first = version_info.get_version_info() + second = version_info.get_version_info() + + # Le cache renvoie le même objet : la résolution n'a pas été refaite. + assert first is second + # Deux commandes (rev-parse + show) pour une résolution, et une seule résolution. + assert len(calls) == 2 + + +def test_get_short_commit_reutilise_le_cache(): + """get_short_commit (pour /healthz) ne lance aucun sous-processus supplémentaire.""" + class _Proc: + returncode = 0 + stdout = "abcdef1" + calls = [] + + def _fake(args, **kwargs): # noqa: ANN001, ANN202 + calls.append(tuple(args)) + return _Proc() + + with patch("core.version_info.subprocess.run", _fake): + version_info.get_version_info() + short = version_info.get_short_commit() + + assert short == "abcdef1" + assert len(calls) == 2 # aucune commande git de plus pour le SHA court + + +# ── 3. /healthz reste public, 200, sans champ sensible ──────────────────────── + +def test_healthz_public_sans_champ_sensible(): + """/healthz répond 200 sans auth et n'ajoute qu'au plus le SHA court.""" + # Aucune vraie commande git : on force le SHA à None (indéterminé). + with patch("core.version_info.get_short_commit", return_value=None): + client = TestClient(_app_with_health()) + r = client.get("/healthz") + assert r.status_code == 200 + data = r.json() + assert data["ok"] is True + assert data["service"] == "tab5-engine" + # Aucun champ sensible ajouté : pas de chemin, de branche, ni de nom de machine. + for champ in ("commit_date", "reason", "branch", "host", "machine", "path", "python"): + assert champ not in data + + +def test_healthz_avec_sha_court_seulement(): + """Quand le SHA est connu, /healthz n'expose QUE le SHA court (rien de plus).""" + class _Proc: + returncode = 0 + stdout = "abcdef1" + with patch("core.version_info.subprocess.run", lambda args, **kwargs: _Proc()): + client = TestClient(_app_with_health()) + data = client.get("/healthz").json() + assert data["commit"] == "abcdef1" + # Et toujours aucun champ sensible. + for champ in ("commit_date", "reason", "branch", "host", "machine", "path", "python"): + assert champ not in data + + +def _app_with_health() -> FastAPI: + """Mini app FastAPI montant uniquement la sonde publique (sans auth).""" + app = FastAPI() + app.include_router(health_router) + return app + + +# ── Route authentifiée /api/observability/version (identité complète) ───────── + +def test_route_authentifiee_expose_identite_complete(): + """La route authentifiée expose SHA + date (pas seulement le SHA court). + + Le module `api/routes/observability` est monté avec `_AUTH_DEP` dans + gui_server.py : cette route est donc protégée (test_sensitive_api_routes_require_auth + le garantit pour toute route /api hors chemins publics). + """ + from api.routes.observability import router as obs_router + + class _Proc: + returncode = 0 + stdout = "abcdef1" + with patch("core.version_info.subprocess.run", lambda args, **kwargs: _Proc()): + app = FastAPI() + app.include_router(obs_router) + data = TestClient(app).get("/api/observability/version").json() + + assert data["commit"] == "abcdef1" + assert data["commit_date"] == "abcdef1" # le mock renvoie la même valeur pour les 2 commandes + assert data["reason"] is None diff --git a/tests/unit/test_vocal_audit_couverture.py b/tests/unit/test_vocal_audit_couverture.py new file mode 100644 index 0000000..7ef6258 --- /dev/null +++ b/tests/unit/test_vocal_audit_couverture.py @@ -0,0 +1,150 @@ +"""test_vocal_audit_couverture.py — #T369. + +Verdict sur « 73 % des latences vocales ne sont pas enregistrées ». + +Ce test verrouille ce qui peut l'être SANS la base de production (elle est sur +le Steam Deck) : + +1. Le comportement du timer `VocalAuditTimer` (déjà couvert par + test_latence_vocale.py, re-vérifié ici pour la couverture) : + - lu À L'INTÉRIEUR du bloc `with` → valeur strictement positive ; + - lu APRÈS le bloc → total figé (deux lectures identiques) ; + - jamais démarré (instancié hors `with`) → 0.0, jamais une durée inventée. + +2. La COUVERTURE des chemins d'écriture dans `vocal_audit_log` : chaque appel + à `log_vocal_response(..., latency_ms=...)` dans `api/routes/agents.py` et + `api/routes/streaming.py` doit être précédé d'un `with VocalAuditTimer()` + et lire `timer.elapsed_ms`. Un chemin qui écrirait une réponse vocale SANS + timer (ou en lisant une valeur non mesurée) est le cas le plus grave : il + enregistrerait `latency_ms = 0` même après #T270. + + L'analyse est STATIQUE (lecture du code source) : elle ne dépend ni du + réseau ni de la base. Elle échoue si un futur chemin ajoute un + `log_vocal_response` sans timer, ou lit une latence qui n'est pas + `timer.elapsed_ms`. +""" +import re +import time +from pathlib import Path + +import pytest + +from core.vocal_audit import VocalAuditTimer + +# Racine du dépôt : remonte depuis tests/unit/ jusqu'à la racine du worktree. +_RACINE = Path(__file__).resolve().parents[2] + +# Fichiers qui écrivent dans vocal_audit_log via log_vocal_response. +_FICHIERS_ECRITURE = [ + _RACINE / "api" / "routes" / "agents.py", + _RACINE / "api" / "routes" / "streaming.py", +] + + +class TestComportementTimer: + """Les trois invariants du timer, verrouillés pour la couverture.""" + + def test_lu_dans_le_bloc_donne_une_valeur_positive(self): + """C'est LE point de #T270 : lire pendant le bloc rend une vraie mesure.""" + with VocalAuditTimer() as timer: + time.sleep(0.02) + mesure = timer.elapsed_ms + + assert mesure > 0, "la latence lue dans le bloc ne doit plus être nulle" + + def test_lu_apres_le_bloc_reste_figee(self): + """Sortir du bloc fige le total : deux lectures donnent la même valeur.""" + with VocalAuditTimer() as timer: + time.sleep(0.01) + + premiere = timer.elapsed_ms + time.sleep(0.02) + assert timer.elapsed_ms == premiere, "la mesure doit être figée après __exit__" + + def test_timer_jamais_demarre_rend_zero(self): + """Instancié hors d'un `with` : 0.0, et surtout pas une durée inventée.""" + assert VocalAuditTimer().elapsed_ms == 0.0 + + +class TestCouvertureCheminsEcriture: + """Aucun chemin d'écriture dans vocal_audit_log ne doit être sans timer. + + Un `log_vocal_response` qui ne lit pas `timer.elapsed_ms` (ou qui n'a pas + de timer du tout) enregistrerait `latency_ms = 0` même après #T270 : c'est + le cas le plus grave, celui qu'il faut interdire par construction. + """ + + @pytest.mark.parametrize("fichier", _FICHIERS_ECRITURE) + def test_chaque_log_vocal_response_a_un_timer(self, fichier): + """Chaque appel log_vocal_response est précédé d'un with VocalAuditTimer().""" + source = fichier.read_text(encoding="utf-8") + + # Découper le fichier en blocs de code (indentation 0) pour associer + # chaque appel à son contexte. + blocs = _decouper_blocs(source) + + appels = [b for b in blocs if "log_vocal_response(" in b] + assert appels, f"{fichier.name} doit contenir au moins un log_vocal_response" + + for bloc in appels: + # Le bloc doit contenir un `with VocalAuditTimer() as timer:`. + assert re.search( + r"with\s+VocalAuditTimer\(\)\s+as\s+timer\s*:", + bloc, + ), ( + f"{fichier.name} : un log_vocal_response n'est pas dans un " + "`with VocalAuditTimer() as timer:` — il enregistrerait " + "latency_ms = 0 même après #T270." + ) + # Et la latence lue doit être `timer.elapsed_ms`. + assert "latency_ms=timer.elapsed_ms" in bloc, ( + f"{fichier.name} : un log_vocal_response ne lit pas " + "`timer.elapsed_ms` — la latence ne serait pas mesurée." + ) + + def test_les_routing_types_attendus_sont_emiss(self): + """Les routing_type du trafic vocal réel sont bien écrits par les deux fichiers. + + `discussion_chat` (chemin bufferisé, agents.py) et + `discussion_chat_stream` (chemin streamé, streaming.py) sont deux + routing_type distincts, écrits par deux fichiers distincts. + """ + agents = (_RACINE / "api" / "routes" / "agents.py").read_text(encoding="utf-8") + streaming = (_RACINE / "api" / "routes" / "streaming.py").read_text(encoding="utf-8") + + # Chemin bufferisé : agents.py écrit le routing_type du host_result, + # qui vaut "discussion_chat" pour le chat (voir core/vocal_host.py). + assert "discussion_chat" in agents + # Chemin streamé : streaming.py écrit explicitement "discussion_chat_stream". + assert 'routing_type="discussion_chat_stream"' in streaming + + def test_aucun_chemin_sans_timer_dans_les_fichiers(self): + """Aucun `VocalAuditTimer()` instancié hors d'un `with` dans les fichiers.""" + for fichier in _FICHIERS_ECRITURE: + source = fichier.read_text(encoding="utf-8") + # Chaque occurrence de VocalAuditTimer() doit être précédée de `with `. + for m in re.finditer(r"VocalAuditTimer\(\)", source): + debut = max(0, m.start() - 10) + contexte = source[debut:m.start()] + assert "with " in contexte, ( + f"{fichier.name} : VocalAuditTimer() instancié hors d'un `with` " + "— ce timer ne serait jamais démarré et rendrait 0.0." + ) + + +def _decouper_blocs(source: str) -> list[str]: + """Découpe un fichier Python en blocs de code au niveau d'indentation 0. + + Chaque bloc commence à une ligne d'indentation 0 et s'étend jusqu'à la + ligne d'indentation 0 suivante. Cela permet d'associer un appel + `log_vocal_response` au `with VocalAuditTimer()` qui l'englobe. + """ + lignes = source.splitlines() + blocs: list[str] = [] + debut = 0 + for i, ligne in enumerate(lignes): + if i > 0 and ligne.strip() and not ligne.startswith((" ", "\t")): + blocs.append("\n".join(lignes[debut:i])) + debut = i + blocs.append("\n".join(lignes[debut:])) + return blocs diff --git a/tests/unit/test_vocal_host.py b/tests/unit/test_vocal_host.py index ce1262e..f9edb8f 100644 --- a/tests/unit/test_vocal_host.py +++ b/tests/unit/test_vocal_host.py @@ -41,6 +41,87 @@ def test_classify_deep(): assert intent == VocalIntent.DEEP +# ───────────────────────────────────────────────────────────── +# [#T351] Reconnaissance des demandes météo / agenda formulées +# naturellement (sans le mot-clé attendu), mesurées en prod via +# vocal_audit_log, + non-régression sur bavardage et commandes HA. +# ───────────────────────────────────────────────────────────── + +# Phrases réelles du vocal_audit_log : aucune ne contient « météo » +# ni « agenda », pourtant l'outil (web / calendrier) existe. +_REAL_METEO_AGENDA_PHRASES = [ + "Quand est-ce que je travaille", + "Quel temps il fait demain ?", + "C'est quand la prochaine fois que je travaille", +] + + +@pytest.mark.parametrize( + "phrase, attendu", + [ + ("Quand est-ce que je travaille", VocalIntent.CALENDAR), + ("Quel temps il fait demain ?", VocalIntent.WEB), + ("C'est quand la prochaine fois que je travaille", VocalIntent.CALENDAR), + # Variantes du corpus #T351 + ("est-ce qu'il va pleuvoir", VocalIntent.WEB), + ("il fait combien dehors", VocalIntent.WEB), + ("je bosse quand", VocalIntent.CALENDAR), + ], +) +def test_classify_formulations_naturelles(phrase, attendu): + """#T351 — les demandes naturelles vont vers l'intention qui possède l'outil.""" + intent, _ = classify_vocal_intent(phrase) + assert intent == attendu + + +@pytest.mark.parametrize( + "phrase", + [ + "raconte-moi une blague", + "comment tu vas", + ], +) +def test_classify_bavardage_reste_chat(phrase): + """#T351 — le bavardage ordinaire ne doit pas être détourné vers web/calendrier.""" + assert classify_vocal_intent(phrase)[0] == VocalIntent.CHAT + + +@pytest.mark.parametrize( + "phrase", + [ + "allume la lumière du salon", + "allume la lumière ce soir", + ], +) +def test_classify_commande_ha_ne_part_pas_calendrier_ni_web(phrase): + """#T351 — une commande domotique ne part NI au calendrier NI au web. + + « ce soir » a été retiré de _CALENDAR_MARKERS : trop court, il détournait + « allume la lumière ce soir » vers le calendrier. + """ + intent, _ = classify_vocal_intent(phrase) + assert intent == VocalIntent.CHAT + + +def test_classify_est_purement_local(): + """#T351 — la classification d'intention ne fait AUCUN appel de gateway. + + C'est une fonction déterministe à base de mots-clés : aucun aller-retour + LLM avant la classification (88 % des réponses vocales sortent en < 1 s). + """ + with patch("core.vocal_host._run_sync_discussion") as mock_discussion, \ + patch("core.vocal_host._try_zero_llm_ha_command") as mock_ha, \ + patch("core.vocal_host._try_zero_llm_ha_state") as mock_state: + for phrase in _REAL_METEO_AGENDA_PHRASES + [ + "raconte-moi une blague", + "allume la lumière du salon", + ]: + classify_vocal_intent(phrase) + mock_discussion.assert_not_called() + mock_ha.assert_not_called() + mock_state.assert_not_called() + + def test_files_prefers_gmail(): from core.vocal_jobs import _files_prefers_gmail @@ -163,14 +244,19 @@ async def test_files_specialist_drive(): @pytest.mark.asyncio async def test_handle_discussion_web_sync(): - """WEB repasse par le spécialiste grounding (sync).""" + """WEB repasse par le spécialiste grounding (sync). + + NB : on utilise une demande web NON météo (« dernier modèle ») : les + demandes météo sont désormais interceptées en Zero-LLM par + `services/ha_weather_query` (voir test_ha_weather_query). + """ with patch( "core.vocal_jobs.run_vocal_specialist", new_callable=AsyncMock, - return_value="Demain à Paris, 24 degrés et ensoleillé.", + return_value="Le dernier modèle de Gemini est disponible." ): result = await handle_discussion( - user_prompt="Quelle météo demain ?", + user_prompt="Quel est le dernier modèle sorti de Gemini ?", session_id="sess_web", gateway=MagicMock(), token_tracker=MagicMock(), @@ -178,7 +264,7 @@ async def test_handle_discussion_web_sync(): ) assert result.async_job_id is None assert result.routing_type == "vocal_host_web" - assert "24" in result.response_text or "Paris" in result.response_text + assert "Gemini" in result.response_text or "modèle" in result.response_text @pytest.mark.asyncio @@ -235,3 +321,45 @@ async def test_handle_discussion_chat_sync(): assert result.routing_type == "discussion_chat" assert result.async_job_id is None assert "Salut" in result.response_text + + +# ── [#T365] Le champ horaire du travail doit atteindre le calendrier ── + +@pytest.mark.parametrize("phrase", [ + # Formulations mesurées en prod le 17/08, toutes routées `chat` (score 0) et + # dont 4 recevaient « Je n'ai pas accès à ton agenda » alors que l'accès marche. + "A quelle heure je commence a travailler demain ?", + "C'est quand mon prochain jour de repos ?", + "je finis a quelle heure demain ?", + "j'ai quoi de prevu ce week-end ?", + "Quel est mon planning de la semaine ?", +]) +def test_formulations_horaires_routees_calendrier(phrase): + from core.vocal_host import VocalIntent, classify_vocal_intent + intent, score = classify_vocal_intent(phrase) + assert intent == VocalIntent.CALENDAR, f"{phrase!r} doit aller au calendrier" + assert score > 0 + + +@pytest.mark.parametrize("phrase", [ + # Garde-fou : les marqueurs sont volontairement longs pour éviter ceci. + "je commence a comprendre la relativite", + "raconte-moi une blague tres courte", + "c'est quoi un volet roulant ?", +]) +def test_pas_de_faux_positif_calendrier(phrase): + from core.vocal_host import VocalIntent, classify_vocal_intent + intent, _ = classify_vocal_intent(phrase) + assert intent != VocalIntent.CALENDAR + + +def test_consigne_chat_interdit_de_nier_agenda_et_domotique(): + """ + Le chat ne voit pas les autres chemins et niait des capacités réelles : + « je n'ai pas accès à ton agenda », « je n'ai pas la capacité de contrôler + le Tab 5 » — alors que 29 commandes HA ont abouti en 30 jours. + """ + from core.vocal_host import _INTENT_SUFFIX, VocalIntent + consigne = _INTENT_SUFFIX[VocalIntent.CHAT] + assert "JAMAIS" in consigne + assert "agenda" in consigne and "domotique" in consigne diff --git a/tests/unit/test_vocal_tools.py b/tests/unit/test_vocal_tools.py index c50cd28..a86d69c 100644 --- a/tests/unit/test_vocal_tools.py +++ b/tests/unit/test_vocal_tools.py @@ -51,7 +51,7 @@ def test_ha_list_prefers_alive_entities(monkeypatch): {"entity_id": "sensor.sonoff_salon_temperature", "state": "unavailable", "attributes": {"friendly_name": "01 Salon Temperature"}}, - {"entity_id": "sensor.thermometre_salon_temperature", + {"entity_id": "sensor.living_room_temperature", "state": "23.3", "attributes": {"friendly_name": "Thermometre salon", "unit_of_measurement": "°C"}}, ] @@ -66,7 +66,7 @@ def test_ha_list_prefers_alive_entities(monkeypatch): with patch.dict("os.environ", {"HASS_TOKEN": "tok", "HASS_URL": "http://ha", "HA_VERIFY_TLS": "false"}): out = _tool_ha_list("salon") - assert "thermometre_salon_temperature" in out # source vivante conservée + assert "living_room_temperature" in out # source vivante conservée assert "sonoff_salon_temperature" not in out # doublon mort écarté diff --git a/tests/unit/test_vocal_tts_cache.py b/tests/unit/test_vocal_tts_cache.py index 341213c..c2f4c85 100644 --- a/tests/unit/test_vocal_tts_cache.py +++ b/tests/unit/test_vocal_tts_cache.py @@ -46,3 +46,40 @@ def test_sanitize_keeps_french_sentence(): ok = "Gemini est une famille de modèles Google, mais je n'ai pas la date exacte de la dernière sortie." assert looks_like_tool_or_code_dump(ok) is False assert "Gemini" in sanitize_discussion_tts(ok) + + +# ── [#T366] Le LaTeX ne doit pas partir au TTS ── + +def test_latex_inline_neutralise(): + """ + Phrase exacte mesurée en prod le 17/08 : « donne-moi la formule de la + relativité générale » rendait la formule brute, ânonnée symbole par symbole + par la synthèse vocale. + """ + brut = ( + "L'equation s'ecrit " + r"\(G_{\mu\nu} + \Lambda\,g_{\mu\nu} = \frac{8\pi G}{c^{4}}\,T_{\mu\nu}\)" + " ou G est le tenseur." + ) + sortie = sanitize_discussion_tts(brut) + assert "\\" not in sortie and "{" not in sortie + assert "la formule" in sortie + # La phrase autour est conservée : on remplace, on ne tronque pas. + assert "tenseur" in sortie + + +def test_latex_display_neutralise(): + sortie = sanitize_discussion_tts("Voici : $$E = mc^2$$ tout simplement.") + assert "$" not in sortie and "la formule" in sortie + + +def test_prix_en_dollars_non_avale(): + """Garde-fou : « 5 $ » n'est pas du LaTeX et doit survivre intact.""" + sortie = sanitize_discussion_tts("Ce cafe coute 5 $ et le the 3 $ seulement.") + assert "5 $" in sortie and "3 $" in sortie + assert "la formule" not in sortie + + +def test_phrase_normale_inchangee(): + texte = "La clim est allumee en mode froid." + assert sanitize_discussion_tts(texte) == texte diff --git a/tests/unit/test_volet_unknown_t385.py b/tests/unit/test_volet_unknown_t385.py new file mode 100644 index 0000000..5d12284 --- /dev/null +++ b/tests/unit/test_volet_unknown_t385.py @@ -0,0 +1,146 @@ +""" +Un volet sans retour de position est en `unknown` — c'est normal, pas une panne. + +#T356 (PR #323) a livré un garde-fou légitime : ne pas annoncer un succès quand +la cible d'une commande est morte, parce que HA répond 200 même sur une entité +`unavailable`. Mais il a mis `unknown` dans le même sac. + +Or, mesuré sur le vrai HA le 17/08 : `cover.living_room_blind` est la SEULE +entité pilotable en `unknown`, c'est le volet UNIQUE de la maison, il FONCTIONNE +(script relancé avec succès à 13:53) — c'est simplement un volet sans capteur de +position. Et `_entite_physique_a_verifier` redirige justement le script de volet +vers cette cover. + +Sans #T385, le déploiement aurait donc fait répondre « le volet de la serre ne +répond pas » à chaque commande de volet, pendant que le volet bouge — sur la +commande vocale la plus utilisée de l'installation. + +Ce banc fige les deux bords : le volet sans position passe, et tout le reste du +garde-fou de #T356 tient (y compris un volet QUI sait rendre sa position). +""" + +import pytest + +import services.execute_service as es + + +class _FakeSession: + """Session HA qui enregistre les POST au lieu de les envoyer.""" + + def __init__(self): + self.calls = [] + + def post(self, *args, **kwargs): + self.calls.append((args, kwargs)) + raise AssertionError("aucun POST ne devait partir dans ce test") + + +def _fake_read_ha_state(payload): + async def _read(entity_id): + return payload + return _read + + +def _brancher_ha(monkeypatch, etat_lu, session=None): + fake = session if session is not None else _FakeSession() + monkeypatch.setattr(es, "_get_ha_session", lambda: fake) + monkeypatch.setattr(es, "_read_ha_credentials", lambda: ("tok", "https://ha.local")) + monkeypatch.setattr(es, "read_ha_state", _fake_read_ha_state(etat_lu)) + return fake + + +# ── LE CAS CENTRAL : le volet unique de la maison ──────────────────────────── + +@pytest.mark.asyncio +async def test_le_volet_sans_position_en_unknown_nest_pas_bloque(monkeypatch): + """`cover.living_room_blind` en `unknown` : la commande doit partir.""" + _brancher_ha(monkeypatch, { + "entity_id": "cover.living_room_blind", + "state": "unknown", + "attributes": {"friendly_name": "Volet de la serre"}, + }) + + blocage = await es._verifier_etat_entite("cover.open_cover", "cover.living_room_blind") + + assert blocage is None, ( + "un volet sans retour de position en 'unknown' est dans son état normal : " + "la commande ne doit pas être bloquée" + ) + + +@pytest.mark.asyncio +async def test_le_script_de_volet_nest_pas_bloque_non_plus(monkeypatch): + """ + Le chemin réel : les commandes de volet passent par le script (#T350), et + `_entite_physique_a_verifier` les redirige vers la cover. C'est ce chemin-là + qui aurait cassé en production. + """ + _brancher_ha(monkeypatch, { + "entity_id": es._VOLET_COVER_ENTITY, + "state": "unknown", + "attributes": {"friendly_name": "Volet de la serre"}, + }) + + blocage = await es._verifier_etat_entite(es._VOLET_SCRIPT, "") + + assert blocage is None, "la commande vocale de volet ne doit pas être refusée" + + +# ── LES BORDS : #T356 doit rester entier partout ailleurs ──────────────────── + +@pytest.mark.asyncio +async def test_un_volet_unavailable_reste_bloque(monkeypatch): + """`unavailable` prouve que HA ne joint pas l'appareil : on bloque toujours.""" + _brancher_ha(monkeypatch, { + "entity_id": "cover.living_room_blind", + "state": "unavailable", + "attributes": {"friendly_name": "Volet de la serre"}, + }) + + blocage = await es._verifier_etat_entite("cover.open_cover", "cover.living_room_blind") + + assert blocage is not None + assert "ne répond pas" in blocage + + +@pytest.mark.asyncio +async def test_un_volet_qui_rend_sa_position_reste_suspect_en_unknown(monkeypatch): + """ + L'exception est étroite : un volet QUI sait rendre sa position et tombe en + `unknown` n'est pas dans son état normal — il reste traité comme suspect. + """ + _brancher_ha(monkeypatch, { + "entity_id": "cover.volet_avec_capteur", + "state": "unknown", + "attributes": {"friendly_name": "Volet du salon", "current_position": 0}, + }) + + blocage = await es._verifier_etat_entite("cover.open_cover", "cover.volet_avec_capteur") + + assert blocage is not None, "l'exception ne doit couvrir que les volets sans position" + + +@pytest.mark.asyncio +async def test_une_lampe_en_unknown_reste_bloquee(monkeypatch): + """Hors du domaine `cover`, #T356 est inchangé (cf. les 6 `light.sonoff_*` morts).""" + _brancher_ha(monkeypatch, { + "entity_id": "light.sonoff_x", + "state": "unknown", + "attributes": {"friendly_name": "Sonoff salon"}, + }) + + blocage = await es._verifier_etat_entite("light.turn_on", "light.sonoff_x") + + assert blocage is not None + assert "ne répond pas" in blocage + + +def test_le_predicat_est_bien_borne(): + """Garde-fou direct sur le helper, sans passer par HA.""" + sans_position = {"attributes": {"friendly_name": "Volet"}} + avec_position = {"attributes": {"current_position": 42}} + + assert es._cover_sans_retour_de_position("cover.living_room_blind", sans_position) is True + assert es._cover_sans_retour_de_position("cover.living_room_blind", avec_position) is False + assert es._cover_sans_retour_de_position("light.living_room", sans_position) is False + assert es._cover_sans_retour_de_position("switch.prise", {}) is False diff --git a/tests/unit/test_zone_grise_catalogue.py b/tests/unit/test_zone_grise_catalogue.py new file mode 100644 index 0000000..3f8ac2f --- /dev/null +++ b/tests/unit/test_zone_grise_catalogue.py @@ -0,0 +1,292 @@ +""" +test_zone_grise_catalogue.py — Zone grise du routage : règle, audit, non-récidive. + +Mesure prod du 17/08 : 52 modèles `active` sans `routing_tier` — ignorés par le +routage par tier (`get_models_for_tier` filtre sur le tier) mais testés chaque +heure par la sonde de vivacité (vraies inférences, vrais tokens). Ces tests +fixent : + + 1. l'outil d'état des lieux (tools/audit_catalogue_sante.py) : sur une base + temporaire, il rend les bons comptes (matrice, profondeur, causes de mort, + classement de la zone grise) ; + 2. la règle explicite : un modèle sans tier est légitime si sa spécialité est + hors routage chat-complétion (SPECIALITES_HORS_ROUTAGE), sinon anomalie ; + 3. la non-récidive : l'audit échoue sur un catalogue fabriqué contenant la + zone grise et passe sur un catalogue sain ; + 4. la surface API : /api/models-health expose les nouveaux compteurs sans + casser sa forme historique (consommateurs existants). + +Toutes les bases sont temporaires et isolées — jamais la base du poste. +""" + +import os +import sqlite3 +import sys +import threading +import time + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) + +import core.models_db as models_db +import core.runtime_db as runtime_db + +# ══════════════════════════════════════════════════════════════════ +# Fabrique de bases temporaires pour l'outil d'audit +# ══════════════════════════════════════════════════════════════════ + +@pytest.fixture +def bases_outil(tmp_path): + """Catalogue + base runtime temporaires, peuplés avec une zone grise connue.""" + chemin_registre = str(tmp_path / "models_registry.db") + chemin_runtime = str(tmp_path / "moteur_runtime.db") + + conn_registre = sqlite3.connect(chemin_registre) + conn_registre.row_factory = sqlite3.Row + models_db._ensure_schema(conn_registre) + conn_registre.execute( + "INSERT INTO providers (id, name, type) VALUES ('p_cloud', 'Cloud', 'api')" + ) + # Un catalogue avec : deux tiers pourvus, un modèle légitime hors routage + # (embeddings), un modèle en zone grise, deux morts de causes distinctes. + conn_registre.executemany( + "INSERT INTO models (id, provider_id, status, routing_tier, speciality) " + "VALUES (?, 'p_cloud', ?, ?, ?)", + [ + ("m-fort", "active", "fort", "raisonnement"), + ("m-moyen", "active", "moyen", "polyvalent"), + ("m-embed", "active", None, "embeddings"), + ("m-gris", "active", None, "polyvalent"), + ("m-mort-sonde", "inactive", None, "polyvalent"), + ("m-mort-humain", "inactive", "moyen", "polyvalent"), + ], + ) + conn_registre.commit() + + conn_runtime = sqlite3.connect(chemin_runtime) + conn_runtime.row_factory = sqlite3.Row + runtime_db._init_schema(conn_runtime) + maintenant = time.time() + conn_runtime.executemany( + "INSERT INTO model_health (model_id, echecs_consecutifs, succes_consecutifs, " + "desactive_par_sonde, dernier_succes) VALUES (?, ?, ?, ?, ?)", + [ + ("m-fort", 0, 12, 0, maintenant), + ("m-gris", 0, 12, 0, maintenant), # vivant mais jamais routé + ("m-embed", 0, 12, 0, maintenant), + ("m-mort-sonde", 112, 0, 1, None), # éteint par la sonde + ], + ) + # Usage réel : m-embed sert (RAG), m-gris n'a JAMAIS été appelé. + conn_runtime.execute( + "INSERT INTO token_usage (timestamp, model, total_tokens) VALUES (?, 'm-embed', 10)", + (maintenant,), + ) + conn_runtime.commit() + + yield conn_registre, conn_runtime + conn_registre.close() + conn_runtime.close() + + +class TestOutilAudit: + """L'outil d'état des lieux rend les bons comptes sur une base temporaire.""" + + def test_matrice_tier_statut(self, bases_outil): + from tools.audit_catalogue_sante import analyser + + rapport = analyser(*bases_outil, cables=None) + matrice = {(e["routing_tier"], e["status"]): e["nb"] for e in rapport["matrice"]} + assert matrice[("fort", "active")] == 1 + assert matrice[("moyen", "active")] == 1 + assert matrice[(None, "active")] == 2 # zone grise + assert matrice[(None, "inactive")] == 1 + assert matrice[("moyen", "inactive")] == 1 + + def test_profondeur_reelle_des_tiers(self, bases_outil): + from tools.audit_catalogue_sante import analyser + + profondeur = analyser(*bases_outil, cables=None)["profondeur_tiers"] + assert profondeur["fort"] == 1 + assert profondeur["moyen"] == 1 + assert profondeur["leger"] == 0 + # Les inactifs sans tier ne comptent PAS dans la zone grise routable. + assert profondeur["hors_tier"] == 2 + + def test_causes_de_mort(self, bases_outil): + from tools.audit_catalogue_sante import analyser + + rapport = analyser(*bases_outil, cables=None) + assert rapport["causes"]["desactive_par_sonde"] == 1 + assert rapport["causes"]["decision_humaine"] == 1 + assert rapport["causes"]["muet_sonde_non_desactive"] == 0 + + def test_zone_grise_classee_par_la_regle(self, bases_outil): + from tools.audit_catalogue_sante import analyser + + rapport = analyser(*bases_outil, cables=None) + assert [e["model_id"] for e in rapport["justifies_hors_routage"]] == ["m-embed"] + gris = rapport["zone_grise"] + assert [e["model_id"] for e in gris] == ["m-gris"] + assert gris[0]["appels_total"] == 0 # jamais servi + assert gris[0]["echecs_consecutifs"] == 0 # vivant, donc sondé pour rien + assert rapport["conforme"] is False # seuil 0 par défaut + assert analyser(*bases_outil, cables=None, seuil=1)["conforme"] is True + + def test_cablage_gateway_transmis(self, bases_outil): + from tools.audit_catalogue_sante import analyser + + avec_cables = analyser(*bases_outil, cables={"m-embed"}) + par_id = {e["model_id"]: e for e in avec_cables["zone_grise"] + avec_cables["justifies_hors_routage"]} + assert par_id["m-embed"]["cable"] is True + assert par_id["m-gris"]["cable"] is False + # Câblage indisponible : None, jamais False inventé. + sans_cables = analyser(*bases_outil, cables=None) + assert sans_cables["zone_grise"][0]["cable"] is None + + +# ══════════════════════════════════════════════════════════════════ +# Règle et non-récidive sur core.models_db (base temporaire isolée) +# ══════════════════════════════════════════════════════════════════ + +@pytest.fixture +def base_catalogue(tmp_path, monkeypatch): + """Catalogue neuf et isolé (jamais la base du poste).""" + chemin = str(tmp_path / "models_registry.db") + monkeypatch.setattr(models_db, "_DB_PATH", chemin) + # La connexion est mémorisée dans un thread-local : le remettre à neuf, + # sinon ce test écrirait dans la base temporaire d'un test précédent. + monkeypatch.setattr(models_db, "_thread_local", threading.local()) + conn = sqlite3.connect(chemin) + models_db._ensure_schema(conn) + conn.execute("INSERT INTO providers (id, name, type) VALUES ('p', 'P', 'api')") + conn.commit() + conn.close() + return chemin + + +def _ajouter(model_id, **champs): + assert models_db.upsert_model(model_id, provider_id="p", **champs) is True + + +class TestRegleZoneGrise: + def test_specialite_hors_routage_legitime(self, base_catalogue): + assert models_db.est_hors_routage_legitime({"speciality": "embeddings"}) + assert models_db.est_hors_routage_legitime({"speciality": "STT"}) # casse + assert not models_db.est_hors_routage_legitime({"speciality": "polyvalent"}) + assert not models_db.est_hors_routage_legitime({"speciality": None}) + + def test_profondeur_tiers_ignore_inactifs(self, base_catalogue): + _ajouter("actif-tier", routing_tier="fort") + _ajouter("actif-gris") + _ajouter("inactif-sans-tier", status="inactive") + + profondeur = models_db.compter_profondeur_tiers() + assert profondeur["fort"] == 1 + assert profondeur["hors_tier"] == 1 # l'inactif n'y est pas + assert profondeur["leger"] == 0 + + +class TestNonRecidive: + """Le test exigé : échec sur catalogue fabriqué avec zone grise, succès sur sain.""" + + def test_catalogue_sain_conforme(self, base_catalogue): + _ajouter("chat-fort", routing_tier="fort") + _ajouter("chat-moyen", routing_tier="moyen") + _ajouter("embed-local", speciality="embeddings") # légitime sans tier + _ajouter("mort-sans-tier", status="inactive") # inactif : hors compte + + audit = models_db.audit_zone_grise() + assert audit["conforme"] is True + assert audit["zone_grise"] == [] + assert audit["justifies"] == ["embed-local"] + + def test_catalogue_avec_zone_grise_echoue(self, base_catalogue): + _ajouter("chat-fort", routing_tier="fort") + # L'anomalie du 17/08 : actif, sans tier, spécialité de chat-complétion — + # la sonde le teste chaque heure, le routeur ne le choisit jamais. + _ajouter("residu-gris", speciality="polyvalent") + + audit = models_db.audit_zone_grise() + assert audit["conforme"] is False + assert audit["zone_grise"] == ["residu-gris"] + + def test_seuil_documente_tolere(self, base_catalogue): + _ajouter("gris-un") + _ajouter("gris-deux") + + assert models_db.audit_zone_grise(seuil=1)["conforme"] is False + assert models_db.audit_zone_grise(seuil=2)["conforme"] is True + + +# ══════════════════════════════════════════════════════════════════ +# Surface API : /api/models-health additive, forme historique intacte +# ══════════════════════════════════════════════════════════════════ + +@pytest.fixture +def base_api(tmp_path, monkeypatch): + """Les deux bases temporaires de la route : registre + santé.""" + chemin_registre = str(tmp_path / "models_registry.db") + chemin_runtime = str(tmp_path / "moteur_runtime.db") + + monkeypatch.setattr(models_db, "_DB_PATH", chemin_registre) + monkeypatch.setattr(models_db, "_thread_local", threading.local()) + monkeypatch.setattr(runtime_db, "_DB_PATH", chemin_runtime) + + conn = sqlite3.connect(chemin_registre) + models_db._ensure_schema(conn) + conn.execute("INSERT INTO providers (id, name, type) VALUES ('p', 'P', 'api')") + conn.commit() + conn.close() + + # Une ligne de santé par modèle : la route lit model_health. + conn_sante = runtime_db.get_connection() + conn_sante.execute( + "INSERT INTO model_health (model_id, echecs_consecutifs, desactive_par_sonde) " + "VALUES ('gris-route', 0, 0)" + ) + conn_sante.execute( + "INSERT INTO model_health (model_id, echecs_consecutifs, desactive_par_sonde) " + "VALUES ('route-fort', 0, 0)" + ) + conn_sante.execute( + "INSERT INTO model_health (model_id, echecs_consecutifs, desactive_par_sonde) " + "VALUES ('eteint-auto', 5, 1)" + ) + conn_sante.commit() + conn_sante.close() + return chemin_registre, chemin_runtime + + +class TestSurfaceApi: + def test_forme_historique_inchangee_et_nouveaux_compteurs(self, base_api): + from api.routes.context import get_models_health + + _ajouter("route-fort", routing_tier="fort") + _ajouter("gris-route") # actif sans tier, présent en santé + _ajouter("eteint-auto", status="inactive") + + reponse = get_models_health() + + # Les consommateurs existants retrouvent TOUTES les clés historiques. + assert {"sonde", "modeles", "muets", "eteints_par_la_sonde"} <= set(reponse) + assert reponse["eteints_par_la_sonde"] == ["eteint-auto"] + + catalogue = reponse["catalogue"] + assert catalogue["profondeur_tiers"]["fort"] == 1 + assert catalogue["profondeur_tiers"]["hors_tier"] == 1 + assert catalogue["nb_sondes_non_routables"] == 1 + assert catalogue["sondes_non_routables"] == ["gris-route"] + assert catalogue["zone_grise"] == ["gris-route"] + assert catalogue["conforme"] is False + + def test_modele_sondable_legitime_hors_routage(self, base_api): + from api.routes.context import get_models_health + + _ajouter("embed-route", speciality="embeddings") + + catalogue = get_models_health()["catalogue"] + assert catalogue["zone_grise"] == [] + assert catalogue["justifies_hors_routage"] == ["embed-route"] + assert catalogue["conforme"] is True diff --git a/tools/api.py b/tools/api.py index 9fdd093..99c3a06 100644 --- a/tools/api.py +++ b/tools/api.py @@ -204,7 +204,7 @@ def call_api(url: str, method: str = "GET", payload_json: str = None, headers_js # (hôtes NON-HA : on ne peut pas préjuger du schéma). # # Mesuré le 12/08 (session `chat_683ea8b2cd`) : l'agent appelait - # `http://192.168.1.x:8123/api/states` alors que Home Assistant n'écoute + # `http://192.168.1.10:8123/api/states` alors que Home Assistant n'écoute # qu'en HTTPS sur ce port. Le serveur ferme la connexion sans répondre → # `RemoteDisconnected` → la tâche échoue après 60,9 s sur une question # aussi simple que « quelle température dans le salon ? ». diff --git a/tools/audit_catalogue_sante.py b/tools/audit_catalogue_sante.py new file mode 100644 index 0000000..c966181 --- /dev/null +++ b/tools/audit_catalogue_sante.py @@ -0,0 +1,302 @@ +""" +tools/audit_catalogue_sante.py — État des lieux du catalogue de modèles croisé +avec la santé de la sonde de vivacité. + +POURQUOI : le 17/08, la mesure en production (models_registry.db du Deck croisé +avec model_health) montrait 52 modèles `active` SANS `routing_tier`. Le routage +par tier les ignore (`get_models_for_tier` filtre sur le tier ET le statut), +mais la sonde de vivacité les teste chaque heure par une vraie inférence : ils +coûtent des tokens et du temps sans jamais pouvoir servir la cascade. Cet outil +rend ce fait MESURABLE et RELANÇABLE — pas un chiffre collé dans une PR — pour +vérifier que la situation ne se redégrade pas. + +Ce que fait l'outil (LECTURE SEULE — il n'écrit RIEN, aucune migration) : + 1. matrice routing_tier × statut (le tableau mesuré le 17/08) ; + 2. profondeur réelle de chaque tier (modèles actifs routables) ; + 3. causes de mort des modèles inactifs (sonde vs décision humaine) ; + 4. détail de la zone grise : chaque modèle actif sans tier, croisé avec la + santé (échecs consécutifs, dernière erreur), l'usage réel (token_usage + depuis toujours) et le câblage gateway ; verdict de la règle explicite + (core.models_db.SPECIALITES_HORS_ROUTAGE : une spécialité hors + chat-complétion justifie l'absence de tier). + +Aucun retrait de modèle n'est proposé automatiquement : les modèles hors tier +restent appelables par nom explicite (proxy /v1, `model_override` IHM/vocal, +dropdown /v1/models). Toute désactivation est une décision humaine, prise après +avoir vérifié les appels explicites — cf. corps de PR. + +Usage : + python tools/audit_catalogue_sante.py # bases du dépôt + python tools/audit_catalogue_sante.py --json # sortie machine + python tools/audit_catalogue_sante.py --seuil 5 # verdict ajusté +""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import sys + +# Permet `python tools/audit_catalogue_sante.py` depuis la racine du dépôt. +_RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _RACINE not in sys.path: + sys.path.insert(0, _RACINE) + +from core.models_db import ( # noqa: E402 + ROUTING_TIERS, + SPECIALITES_HORS_ROUTAGE, + est_hors_routage_legitime, +) + +CHEMIN_REGISTRE_DEFAUT = os.path.join(_RACINE, "models_registry.db") +CHEMIN_RUNTIME_DEFAUT = os.path.join(_RACINE, "moteur_runtime.db") + + +def _ouvrir_lecture_seule(chemin: str) -> sqlite3.Connection | None: + """Ouvre la base EN LECTURE SEULE (URI mode=ro). None si absente.""" + if not os.path.exists(chemin): + return None + conn = sqlite3.connect(f"file:{chemin}?mode=ro", uri=True) + conn.row_factory = sqlite3.Row + return conn + + +# ══════════════════════════════════════════════════════════════════ +# Analyse (pure : connexions passées en argument → testable sur bases +# temporaires sans toucher aux bases livrées) +# ══════════════════════════════════════════════════════════════════ + +def analyser( + conn_registre: sqlite3.Connection, + conn_runtime: sqlite3.Connection | None = None, + cables: set[str] | None = None, + seuil: int = 0, +) -> dict: + """ + Croise catalogue (models_registry.db) et santé/usage (moteur_runtime.db). + + `cables` : noms (minuscules) des modèles câblés dans la gateway. None = + information indisponible (la gateway n'a pas pu être instanciée) — le + rapport l'indique sans conclure. + """ + rapport: dict = {"seuil": seuil} + + # ── 1. Matrice routing_tier × statut ── + lignes = conn_registre.execute( + "SELECT COALESCE(routing_tier, '') AS tier, status, COUNT(*) AS nb " + "FROM models GROUP BY COALESCE(routing_tier, ''), status" + ).fetchall() + rapport["matrice"] = [ + {"routing_tier": ligne["tier"] or None, "status": ligne["status"], "nb": ligne["nb"]} + for ligne in lignes + ] + + # ── 2. Profondeur réelle des tiers (actifs uniquement) ── + profondeur = {tier: 0 for tier in ROUTING_TIERS} + profondeur["hors_tier"] = 0 + for ligne in conn_registre.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(): + profondeur[ligne["tier"]] = ligne["nb"] + rapport["profondeur_tiers"] = profondeur + + # ── Santé et usage (facultatifs : l'outil marche sans la base runtime) ── + sante: dict[str, sqlite3.Row] = {} + appels: dict[str, int] = {} + if conn_runtime is not None: + try: + for ligne in conn_runtime.execute( + "SELECT model_id, echecs_consecutifs, succes_consecutifs, " + "desactive_par_sonde, derniere_erreur, dernier_succes " + "FROM model_health" + ).fetchall(): + sante[ligne["model_id"]] = ligne + except sqlite3.Error: + pass # table model_health absente (base plus ancienne) + try: + for ligne in conn_runtime.execute( + "SELECT model, COUNT(*) AS nb FROM token_usage GROUP BY model" + ).fetchall(): + appels[ligne["model"]] = ligne["nb"] + except sqlite3.Error: + pass # table token_usage absente + rapport["sante_disponible"] = bool(sante) + rapport["usage_disponible"] = bool(appels) + + # ── 3. Causes de mort des modèles inactifs ── + morts = [] + for ligne in conn_registre.execute( + "SELECT id, speciality FROM models WHERE status = 'inactive' ORDER BY id" + ).fetchall(): + etat = sante.get(ligne["id"]) + if etat is not None and etat["desactive_par_sonde"]: + cause = "desactive_par_sonde" + elif etat is not None and (etat["echecs_consecutifs"] or 0) > 0: + cause = "muet_sonde_non_desactive" + else: + cause = "decision_humaine" + morts.append({ + "model_id": ligne["id"], + "cause": cause, + "echecs_consecutifs": (etat["echecs_consecutifs"] or 0) if etat else 0, + "derniere_erreur": etat["derniere_erreur"] if etat else None, + }) + rapport["morts"] = morts + rapport["causes"] = { + cause: sum(1 for m in morts if m["cause"] == cause) + for cause in ("desactive_par_sonde", "muet_sonde_non_desactive", "decision_humaine") + } + + # ── 4. Zone grise : actifs sans tier, croisés santé/usage/câblage ── + zone_grise, justifies = [], [] + for ligne in conn_registre.execute( + "SELECT * FROM models WHERE status = 'active' AND routing_tier IS NULL " + "ORDER BY provider_id, id" + ).fetchall(): + modele = dict(ligne) + etat = sante.get(modele["id"]) + entree = { + "model_id": modele["id"], + "provider_id": modele["provider_id"], + "tier_catalogue": modele.get("tier"), + "speciality": modele.get("speciality"), + "cable": None if cables is None else modele["id"].lower() in cables, + "appels_total": appels.get(modele["id"], 0), + "echecs_consecutifs": (etat["echecs_consecutifs"] or 0) if etat else 0, + "dernier_succes": etat["dernier_succes"] if etat else None, + "justifie": est_hors_routage_legitime(modele), + } + (justifies if entree["justifie"] else zone_grise).append(entree) + rapport["zone_grise"] = zone_grise + rapport["justifies_hors_routage"] = justifies + rapport["conforme"] = len(zone_grise) <= seuil + rapport["regle"] = ( + "Un modèle actif sans routing_tier est légitime si sa spécialité est " + f"hors routage chat-complétion ({sorted(SPECIALITES_HORS_ROUTAGE)}) ; " + "sinon c'est une anomalie : sondé chaque heure, jamais routable." + ) + return rapport + + +def noms_cables_dans_la_gateway() -> set[str] | None: + """Modèles réellement câblés dans le gateway ; None si instanciation impossible. + + Aucune requête réseau : l'instanciation ne fait qu'enregistrer les + providers. En cas d'échec (dépendance manquante…), l'information est + signalée indisponible plutôt qu'inventée. + """ + try: + from core.llm_gateway import LLMGateway + return {str(n).lower() for n in LLMGateway().providers.keys()} + except Exception: + return None + + +# ══════════════════════════════════════════════════════════════════ +# Présentation +# ══════════════════════════════════════════════════════════════════ + +def formater_texte(rapport: dict) -> str: + lignes: list[str] = [] + sep = "─" * 78 + + lignes.append(sep) + lignes.append("AUDIT CATALOGUE × SANTÉ — zone grise du routage par tier") + lignes.append(sep) + + lignes.append("\n1. Matrice routing_tier × statut") + lignes.append(f" {'routing_tier':<14} {'status':<10} {'nb':>4}") + for entree in sorted( + rapport["matrice"], + key=lambda e: (e["routing_tier"] or "", e["status"]), + ): + lignes.append( + f" {(entree['routing_tier'] or '(vide)'):<14} " + f"{entree['status']:<10} {entree['nb']:>4}" + ) + + lignes.append("\n2. Profondeur réelle des tiers (modèles ACTIFS routables)") + for tier in (*ROUTING_TIERS, "hors_tier"): + lignes.append(f" {tier:<10} {rapport['profondeur_tiers'].get(tier, 0):>3}") + + lignes.append("\n3. Modèles inactifs par cause de mort") + for cause, nb in rapport["causes"].items(): + lignes.append(f" {cause:<26} {nb:>3}") + + lignes.append( + "\n4. Zone grise — actifs sans tier (règle : spécialité hors routage = légitime)" + ) + for entree in rapport["justifies_hors_routage"]: + lignes.append( + f" [LÉGITIME] {entree['model_id']:<38} spécialité={entree['speciality']}" + ) + for entree in rapport["zone_grise"]: + cable = "câblé" if entree["cable"] else ( + "NON câblé" if entree["cable"] is False else "câblage inconnu" + ) + lignes.append( + f" [GRIS] {entree['model_id']:<38} provider={entree['provider_id']:<18} " + f"speciality={entree['speciality'] or '(aucune)':<18} {cable}, " + f"appels_total={entree['appels_total']}, " + f"echecs_consecutifs={entree['echecs_consecutifs']}" + ) + + verdict = "CONFORME" if rapport["conforme"] else "NON CONFORME" + lignes.append(sep) + lignes.append( + f"VERDICT (seuil={rapport['seuil']}) : {verdict} — " + f"{len(rapport['zone_grise'])} modèle(s) en zone grise, " + f"{len(rapport['justifies_hors_routage'])} légitime(s) hors routage." + ) + lignes.append( + "RAPPEL : cet outil n'écrit rien. Ne jamais désactiver en masse la zone " + "grise — ces modèles restent appelables par nom (proxy /v1, " + "model_override) ; tout retrait est une décision humaine vérifiée." + ) + return "\n".join(lignes) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="État des lieux du catalogue de modèles croisé avec la santé " + "(lecture seule, relançable, n'écrit rien).", + ) + parser.add_argument("--registre", default=CHEMIN_REGISTRE_DEFAUT, + help="Chemin de models_registry.db") + parser.add_argument("--runtime", default=CHEMIN_RUNTIME_DEFAUT, + help="Chemin de moteur_runtime.db (santé + usage)") + parser.add_argument("--json", action="store_true", + help="Sortie JSON (reproductible, consommable par un script)") + parser.add_argument("--seuil", type=int, default=0, + help="Nombre maximal de modèles en zone grise toléré (défaut 0)") + parser.add_argument("--sans-gateway", action="store_true", + help="Ne pas instancier la gateway (info câblage absente)") + args = parser.parse_args(argv) + + conn_registre = _ouvrir_lecture_seule(args.registre) + if conn_registre is None: + print(f"Base registre introuvable : {args.registre}", file=sys.stderr) + return 1 + conn_runtime = _ouvrir_lecture_seule(args.runtime) + + cables = None if args.sans_gateway else noms_cables_dans_la_gateway() + try: + rapport = analyser(conn_registre, conn_runtime, cables=cables, seuil=args.seuil) + finally: + conn_registre.close() + if conn_runtime is not None: + conn_runtime.close() + + if args.json: + print(json.dumps(rapport, ensure_ascii=False, indent=2, default=str)) + else: + print(formater_texte(rapport)) + return 0 if rapport["conforme"] else 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/context_self_healing.py b/tools/context_self_healing.py index 38a8eb1..95f6207 100644 --- a/tools/context_self_healing.py +++ b/tools/context_self_healing.py @@ -168,17 +168,17 @@ def _validate_ip_addresses(self): # Ajouter les IPs de l'infrastructure (DHCP statiques / PC / HA) pour éviter les faux positifs known_infra_ips = { - "192.168.1.x", # Host PC de dev (LM Studio) - "192.168.1.x", # PC Ethernet primaire - "192.168.1.x", # PC Ethernet secondaire - "192.168.1.x", # M5Stack Tab5 V2 (DHCP lease) - "192.168.1.x", # MicHA AtomS3R (DHCP lease) + "192.168.1.10", # Host PC de dev (LM Studio) + "192.168.1.10", # PC Ethernet primaire + "192.168.1.10", # PC Ethernet secondaire + "192.168.1.10", # M5Stack Tab5 V2 (DHCP lease) + "192.168.1.10", # MicHA AtomS3R (DHCP lease) "192.168.1.10", # HA VM Local IP (NGINX proxy) - "192.168.1.x", # VM Freebox (Worker Sentinelle) - "192.168.1.x", # Passerelle Freebox Delta - "192.168.1.x", # Steam Deck (Prod Moteur / Failover HA) - "192.168.1.x", # Failover IP secondaire - "192.168.1.x", # Coordinateur Zigbee SMLIGHT SLZB-06MU + "192.168.1.10", # VM Freebox (Worker Sentinelle) + "192.168.1.10", # Passerelle Freebox Delta + "192.168.1.10", # Steam Deck (Prod Moteur / Failover HA) + "192.168.1.10", # Failover IP secondaire + "192.168.1.10", # Coordinateur Zigbee SMLIGHT SLZB-06MU } all_known_ips = esphome_ips | workers_ips | known_infra_ips diff --git a/tools/failover_manager.py b/tools/failover_manager.py index 426f8b4..1d2b702 100644 --- a/tools/failover_manager.py +++ b/tools/failover_manager.py @@ -41,7 +41,7 @@ class FailoverManager: cohérent sur la disponibilité du Deck dans le Moteur. Usage : - mgr = FailoverManager(mqtt_host="192.168.1.x") + mgr = FailoverManager(mqtt_host="192.168.1.10") await mgr.start() status = await mgr.get_deck_status() await mgr.stop() @@ -57,7 +57,7 @@ def __new__(cls, *args, **kwargs): def __init__( self, - mqtt_host: str = "192.168.1.x", # PC écoute en tant que broker + mqtt_host: str = "192.168.1.10", # PC écoute en tant que broker mqtt_port: int = 1883, client_id: str = "moteur_failover_manager", ): diff --git a/tools/git_safety.py b/tools/git_safety.py index 04d46ee..22941d0 100644 --- a/tools/git_safety.py +++ b/tools/git_safety.py @@ -17,7 +17,7 @@ # remote) sans pointer vers ce dépôt, on refuse d'opérer dessus plutôt que de # committer/merger silencieusement dans un dépôt orphelin non lié à l'historique # réel (cf. incident Steam Deck du 2026-06-27 : ~400 fichiers absorbés dans un -# dépôt sans remote créé par erreur dans /home/deck/dev_station/moteur_agents). +# dépôt sans remote créé par erreur dans /opt/vromvrom-engine). EXPECTED_REMOTE_SUBSTR = "Axellum/moteur_agents" def _run_git(args: list, cwd: str = ".") -> tuple[int, str, str]: diff --git a/tools/ha_entity_ingest.py b/tools/ha_entity_ingest.py index 4c92069..e7a6819 100644 --- a/tools/ha_entity_ingest.py +++ b/tools/ha_entity_ingest.py @@ -84,9 +84,9 @@ async def ingest_ha_entities(): for entity in entities: entity_id = entity.get("entity_id", "") if "tab5" in entity_id.lower() or entity_id in ( - "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" ): tab5_entities.append(entity) diff --git a/tools/night_orchestrator.py b/tools/night_orchestrator.py index 84652e9..0ce9f2e 100644 --- a/tools/night_orchestrator.py +++ b/tools/night_orchestrator.py @@ -28,9 +28,9 @@ # Fichiers de règles et leçons à injecter en contexte pour guider Vertex AI RULES_FILES = [ - r"e:\AuxFilsDesIdees\contexte_ia\01_Core\rules_global.md", - r"e:\AuxFilsDesIdees\contexte_ia\03_Software\rules_moteur_agents.md", - r"e:\AuxFilsDesIdees\contexte_ia\03_Software\lecons_gcp_apis.md", + r".\contexte_ia\01_Core\rules_global.md", + r".\contexte_ia\03_Software\rules_moteur_agents.md", + r".\contexte_ia\03_Software\lecons_gcp_apis.md", ] def refresh_oauth(): @@ -275,7 +275,7 @@ def main(): print("\n--- Étape 5 : Audit long-contexte de 00ProjetTab ---") report_lines.append("\n## 3. Audits long-contexte de nuit") - tab5_dir = r"e:\AuxFilsDesIdees\00ProjetTab" + tab5_dir = r".\00ProjetTab" audit_out = r"docs\audits\audit_tab5_de_nuit.md" if os.path.exists(tab5_dir): diff --git a/tools/system.py b/tools/system.py index 7f58d07..72bbe74 100644 --- a/tools/system.py +++ b/tools/system.py @@ -24,7 +24,7 @@ # [P0-audit-2026-07-09] Racine autorisée pour write_file/read_file. Par défaut le # dossier PARENT de moteur_agents/ (workspace canonique documenté dans CLAUDE.md, -# ex. H:\AuxFilsDesIdees ; sur Linux/Deck, parent du dossier de déploiement) — +# ex. . ; sur Linux/Deck, parent du dossier de déploiement) — # même profondeur (3 dirname depuis un fichier de tools/) que project_root plus bas # dans validate_config_yaml(). Surchargeable via MOTEUR_WORKSPACE_ROOT si la # topologie diffère. realpath() résout aussi les symlinks/junctions (E:/H:), même @@ -178,8 +178,8 @@ def validate_config_yaml(file_path: str) -> str: return f"Erreur: Le fichier {file_path} n'existe pas." # Résoudre le chemin de l'exécutable esphome dans le .venv - # e:\AuxFilsDesIdees\moteur_agents\tools\system.py -> le dossier parent de moteur_agents est e:\AuxFilsDesIdees - # et .venv est à e:\AuxFilsDesIdees\.venv + # .\tools\system.py -> le dossier parent de moteur_agents est . + # et .venv est à .\.venv project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) is_windows = (os.name == 'nt') diff --git a/tools/tableau_de_bord.py b/tools/tableau_de_bord.py index 329230b..9d96097 100644 --- a/tools/tableau_de_bord.py +++ b/tools/tableau_de_bord.py @@ -216,7 +216,7 @@ def est_prod(racine: str | None = None) -> bool: affiche la référence sans calculer d'écart. """ cible = os.path.abspath(racine or RACINE).replace("\\", "/") - return cible.startswith("/home/deck/") or os.path.isdir("/home/deck/dev_station/moteur_agents") + return cible.startswith("/home/deck/") or os.path.isdir("/opt/vromvrom-engine") def _delta(actuel, reference, racine=None) -> str: From d876a3e6936bf4826b7893d37fb10dda5f94fc7b Mon Sep 17 00:00:00 2001 From: Axel Duviella Date: Tue, 18 Aug 2026 11:18:52 +0200 Subject: [PATCH 2/3] =?UTF-8?q?fix(oss):=20fixtures=20CI=20=E2=80=94=20exa?= =?UTF-8?q?mple=20routing=5Fpolicy=20+=20deux=20hotes=20Deck=20distincts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le test d'exclusion fable5 lit config.example.json (pas le config.json prive). Ethernet/Wi-Fi Deck ne partagent plus la meme IP exemple, sinon le failover ne peut pas basculer. Co-authored-by: Cursor --- core/llm/builtin_providers.py | 2 +- core/llm/providers/deepseek.py | 4 ++-- core/llm_gateway.py | 2 +- tests/unit/test_gateway_direct_cb.py | 16 ++++++++++------ tests/unit/test_ollama_deck.py | 8 ++++---- 5 files changed, 18 insertions(+), 14 deletions(-) diff --git a/core/llm/builtin_providers.py b/core/llm/builtin_providers.py index 84ef044..c46571b 100644 --- a/core/llm/builtin_providers.py +++ b/core/llm/builtin_providers.py @@ -408,7 +408,7 @@ class AjeanPlugin(_PluginOpenAICompat): 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, build b10451) sur le PC d'Axel, 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, pour l'instance Deck à venir — pas un provider distinct, d'où l'absence de descripteur propre (même logique qu'ollama_local/ollama_pc). NON branché en cascade tant que l'hôte n'écoute pas sur le LAN (action d'Axel : --host 0.0.0.0 + pare-feu 8080).", + 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, diff --git a/core/llm/providers/deepseek.py b/core/llm/providers/deepseek.py index d56d18c..b82278f 100644 --- a/core/llm/providers/deepseek.py +++ b/core/llm/providers/deepseek.py @@ -259,11 +259,11 @@ class OllamaDeckProvider(LLMProvider): Compatible API OpenAI (format identique à LMStudioProvider). """ - DECK_HOSTS = ["192.168.1.10", "192.168.1.10"] # 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 = "192.168.1.10", + 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 198549a..5ef0e80 100644 --- a/core/llm_gateway.py +++ b/core/llm_gateway.py @@ -492,7 +492,7 @@ def _make_anthropic(model: str) -> AnthropicNativeProvider: "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 d'Axel (192.168.1.10) et le Deck (127.0.0.1). + # 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 diff --git a/tests/unit/test_gateway_direct_cb.py b/tests/unit/test_gateway_direct_cb.py index 28cb5b0..1824a9c 100644 --- a/tests/unit/test_gateway_direct_cb.py +++ b/tests/unit/test_gateway_direct_cb.py @@ -9,6 +9,8 @@ get_provider_for_tier(). """ +import json + import pytest from core.llm.circuit_breaker import CircuitBreaker @@ -103,16 +105,18 @@ def test_routing_policy_exclut_fable5_du_routage_auto(gateway): assert "claude-fable-5" not in allowed -def test_routing_policy_reelle_exclut_fable5(): - """Le config.json réel du repo doit porter l'exclusion (documentation - exécutable — empêche une session future de la retirer par erreur).""" - from core.llm_gateway import load_config - policy = load_config().get("routing_policy", {}) +def test_routing_policy_exemple_exclut_fable5(): + """Le config.example.json public documente l'exclusion (pas le config.json + privé, absent du miroir OSS).""" + from pathlib import Path + example = Path(__file__).resolve().parents[2] / "config.example.json" + if not example.is_file(): + pytest.skip("config.example.json absent") + policy = json.loads(example.read_text(encoding="utf-8")).get("routing_policy", {}) exclus = policy.get("excluded_models", []) assert "claude-fable-5" in exclus assert "claude-sonnet-5" in exclus assert "claude-opus-4-8-direct" in exclus - assert "meta-llama/llama-3.3-70b-instruct:free" in exclus def test_exclusions_defaut_couvrent_api_anthropic_et_or_free(): diff --git a/tests/unit/test_ollama_deck.py b/tests/unit/test_ollama_deck.py index 77913d5..4e25324 100644 --- a/tests/unit/test_ollama_deck.py +++ b/tests/unit/test_ollama_deck.py @@ -49,7 +49,7 @@ class TestOllamaDeckProvider: def test_init_url_defaut(self, ollama_provider): """Vérifie que l'URL par défaut pointe bien vers le Deck (IP Ethernet).""" - assert "192.168.1.10" in ollama_provider.base_url + assert "192.168.1.20" in ollama_provider.base_url assert "11434" in ollama_provider.base_url assert ollama_provider.model_name == "phi3:mini" @@ -78,13 +78,13 @@ def test_ping_available_basculement_wifi(self): import requests from core.llm_gateway import OllamaDeckProvider - p = OllamaDeckProvider(host="192.168.1.10") + p = OllamaDeckProvider(host="192.168.1.20") mock_tags_wifi = MagicMock() mock_tags_wifi.status_code = 200 def mock_get_side_effect(url, **kwargs): - if "192.168.1.10" in url: + if "192.168.1.20" in url: raise requests.exceptions.ConnectTimeout return mock_tags_wifi # Wi-Fi répond @@ -93,7 +93,7 @@ def mock_get_side_effect(url, **kwargs): assert result is True # Vérifier que l'IP a bien basculé vers le Wi-Fi - assert "192.168.1.10" in p.base_url + assert "192.168.1.21" in p.base_url def test_generate_reponse_ok(self, ollama_provider, mock_response_ok): """Test generate() avec une réponse Ollama valide.""" From 6b99b19ad89d9df493b2de6ca95a03b05f2e7087 Mon Sep 17 00:00:00 2001 From: Axel Duviella Date: Tue, 18 Aug 2026 11:27:18 +0200 Subject: [PATCH 3/3] feat(oss): inclure l'IHM React complete (ihm-v2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le miroir n'avait qu'un stub (24 fichiers, sans package.json). On recopie les 68 sources trackees du prive — Chat, HA/vocal, catalogue LLM, Prompt Studio. Pas de node_modules/dist, scan secrets OK. README : npm run build puis gui_server. Co-authored-by: Cursor --- README.fr.md | 25 +- README.md | 25 +- ihm-v2/.gitignore | 10 + ihm-v2/README.md | 2 +- ihm-v2/index.html | 12 + ihm-v2/package-lock.json | 3926 +++++++++++++++++ ihm-v2/package.json | 41 + ihm-v2/src/api/agents.ts | 82 + ihm-v2/src/api/chat.ts | 159 + ihm-v2/src/api/client.ts | 105 + ihm-v2/src/api/config.ts | 31 + ihm-v2/src/api/engine.ts | 25 + ihm-v2/src/api/models.ts | 168 + ihm-v2/src/api/observability.ts | 7 + ihm-v2/src/api/operations.ts | 96 + ihm-v2/src/api/prompt.ts | 13 + ihm-v2/src/api/sessions.ts | 61 + .../src/components/charts/registerCharts.ts | 31 + ihm-v2/src/components/common/ApiKeyGate.tsx | 57 + .../src/components/common/ErrorBoundary.tsx | 44 + .../components/common/MarkdownRenderer.tsx | 161 + .../src/components/common/RealTimePanel.tsx | 217 + .../src/components/common/SessionHistory.tsx | 138 + ihm-v2/src/components/common/StatusBadge.tsx | 22 + .../openfox_prep/CustomModelModal.tsx | 317 ++ .../components/openfox_prep/ThinkingBlock.tsx | 62 + .../openfox_prep/ToolCallDisplay.tsx | 95 + .../openfox_prep/ToolPermissionSelector.tsx | 264 ++ ihm-v2/src/hooks/useEngineStream.ts | 104 + ihm-v2/src/index.css | 19 + ihm-v2/src/lib/dotpath.ts | 29 + ihm-v2/src/state/chatStore.ts | 256 ++ ihm-v2/src/state/engineStore.ts | 74 + ihm-v2/src/types/engine.ts | 70 + ihm-v2/src/types/observability.ts | 54 + ihm-v2/src/views/APIAccounts.tsx | 11 +- ihm-v2/src/views/AgentsManager.tsx | 430 ++ ihm-v2/src/views/Chat.tsx | 385 ++ ihm-v2/src/views/Dashboard.tsx | 185 + ihm-v2/src/views/LLMRegistry.tsx | 528 +++ ihm-v2/src/views/Observability.tsx | 214 + ihm-v2/src/views/Operations.tsx | 318 ++ ihm-v2/src/views/PromptStudio.tsx | 330 ++ ihm-v2/src/vite-env.d.ts | 1 + ihm-v2/tsconfig.app.json | 23 + ihm-v2/tsconfig.json | 7 + ihm-v2/tsconfig.node.json | 21 + ihm-v2/vite.config.ts | 27 + 48 files changed, 9256 insertions(+), 26 deletions(-) create mode 100644 ihm-v2/.gitignore create mode 100644 ihm-v2/index.html create mode 100644 ihm-v2/package-lock.json create mode 100644 ihm-v2/package.json create mode 100644 ihm-v2/src/api/agents.ts create mode 100644 ihm-v2/src/api/chat.ts create mode 100644 ihm-v2/src/api/client.ts create mode 100644 ihm-v2/src/api/config.ts create mode 100644 ihm-v2/src/api/engine.ts create mode 100644 ihm-v2/src/api/models.ts create mode 100644 ihm-v2/src/api/observability.ts create mode 100644 ihm-v2/src/api/operations.ts create mode 100644 ihm-v2/src/api/prompt.ts create mode 100644 ihm-v2/src/api/sessions.ts create mode 100644 ihm-v2/src/components/charts/registerCharts.ts create mode 100644 ihm-v2/src/components/common/ApiKeyGate.tsx create mode 100644 ihm-v2/src/components/common/ErrorBoundary.tsx create mode 100644 ihm-v2/src/components/common/MarkdownRenderer.tsx create mode 100644 ihm-v2/src/components/common/RealTimePanel.tsx create mode 100644 ihm-v2/src/components/common/SessionHistory.tsx create mode 100644 ihm-v2/src/components/common/StatusBadge.tsx create mode 100644 ihm-v2/src/components/openfox_prep/CustomModelModal.tsx create mode 100644 ihm-v2/src/components/openfox_prep/ThinkingBlock.tsx create mode 100644 ihm-v2/src/components/openfox_prep/ToolCallDisplay.tsx create mode 100644 ihm-v2/src/components/openfox_prep/ToolPermissionSelector.tsx create mode 100644 ihm-v2/src/hooks/useEngineStream.ts create mode 100644 ihm-v2/src/index.css create mode 100644 ihm-v2/src/lib/dotpath.ts create mode 100644 ihm-v2/src/state/chatStore.ts create mode 100644 ihm-v2/src/state/engineStore.ts create mode 100644 ihm-v2/src/types/engine.ts create mode 100644 ihm-v2/src/types/observability.ts create mode 100644 ihm-v2/src/views/AgentsManager.tsx create mode 100644 ihm-v2/src/views/Chat.tsx create mode 100644 ihm-v2/src/views/Dashboard.tsx create mode 100644 ihm-v2/src/views/LLMRegistry.tsx create mode 100644 ihm-v2/src/views/Observability.tsx create mode 100644 ihm-v2/src/views/Operations.tsx create mode 100644 ihm-v2/src/views/PromptStudio.tsx create mode 100644 ihm-v2/src/vite-env.d.ts create mode 100644 ihm-v2/tsconfig.app.json create mode 100644 ihm-v2/tsconfig.json create mode 100644 ihm-v2/tsconfig.node.json create mode 100644 ihm-v2/vite.config.ts 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/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 ? ( +
    +